diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 8ee8271..1bdde9d 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -191,8 +191,14 @@ storage path after all build, VM, signing, and clean-client gates. No workflow contains a separate disposable qualification job. Pull requests discover authenticated Debian source, run the fast and release -preflight tiers, always build and VM-test v2/v3 through isolated cache transport, -run the package matrix, and exercise a disposable signed repository. Their typed +preflight tiers, build and VM-test v2/v3 through isolated cache transport, run +the package matrix, and exercise a disposable signed repository. The one +exception is a documentation-only pull request: when its merge commit changes +only Markdown files and the documentation-site paths listed in `dkc/github.py`, +the qualification sets `build_required=false` and the release preflight, kernel, +and package jobs are skipped. The site workflow's required `documentation site` +check still builds and audits the site. Any other path, an unreadable change +set, or a checkout that is not a two-parent merge commit keeps the full build. Their typed qualification has no publication authority; authoritative-state reads, production signing, publication, and final state verification remain skipped. Jobs that cross those deliberately skipped production-only branches use @@ -201,9 +207,11 @@ producer. This prevents GitHub's skipped-ancestor propagation from suppressing the pull-request build while keeping genuine dependency failures fail-closed. The `pull request result` job is the workflow's single required check. GitHub reports a job skipped after a failed dependency as a passed check, so that job -runs under `!cancelled()` and `make github-pull-request-result` fails unless -every direct dependency succeeded. Its only dependency is the package matrix, -whose condition already requires every earlier pull-request job to succeed. +runs under `!cancelled()`, and `make github-pull-request-result` checks each +result itself. The fast tier and the decision must succeed. The package matrix, +whose condition already requires every earlier kernel job to succeed, must +succeed as well, unless the successful decision did not require kernel builds; +then it must be skipped. Unlike `always()`, `!cancelled()` turns false when GitHub re-evaluates running jobs on cancellation, so a newer push really cancels a superseded pull-request run instead of leaving it to hold the concurrency group. A manually cancelled diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78c4f53..74d7d64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -272,6 +272,9 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # A pull request's merge commit and both parents: the qualification + # compares them to skip kernel builds for documentation-only changes. + fetch-depth: 2 - name: Download this pull request's toolbox image if: needs.container_images.outputs.image_mode == 'loaded' @@ -1142,12 +1145,14 @@ jobs: name: pull request result # The single required check of this workflow. GitHub reports a job that is # skipped after a failed dependency as a passed check, so this job always - # runs and fails unless every direct dependency succeeded. The package - # matrix already requires every earlier pull-request job to succeed. + # runs and checks each result itself. The fast tier and the decision must + # succeed. The package matrix requires every earlier kernel job, so it must + # succeed too, unless the decision found a documentation-only change and + # skipped the kernel builds. if: >- !cancelled() && github.event_name == 'pull_request' - needs: [package-matrix] + needs: [fast, lifecycle-decision, package-matrix] runs-on: ubuntu-26.04 timeout-minutes: 10 steps: @@ -1159,7 +1164,10 @@ jobs: - name: Require every pull-request job to succeed run: >- make github-pull-request-result - GITHUB_NEEDS_RESULTS='${{ join(needs.*.result) }}' + GITHUB_FAST_RESULT='${{ needs.fast.result }}' + GITHUB_DECISION_RESULT='${{ needs.lifecycle-decision.result }}' + GITHUB_BUILD_REQUIRED='${{ needs.lifecycle-decision.outputs.build_required }}' + GITHUB_BUILD_RESULT='${{ needs.package-matrix.result }}' lifecycle-result: name: lifecycle result diff --git a/CHANGES.md b/CHANGES.md index e176826..30ea5d3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -15,9 +15,16 @@ cutting a version are in [Maintaining this file](#maintaining-this-file). and query-bearing site requests, and `/index.html`, redirect to the canonical HTTPS URL. Operators add them after the new site build is published. See [docs/CLOUDFLARE_CACHE.md](docs/CLOUDFLARE_CACHE.md#8-redirect-site-aliases). +- The documentation site measures visits with Google Analytics, which loads + only after a visitor accepts the new cookie consent dialog. See + [docs/SITE.md](docs/SITE.md#analytics-and-consent). ### Changed +- Pull requests that change only Markdown documentation or the documentation + site skip the kernel builds and package matrix; the fast tier and the site + check still run. See + [.github/workflows/README.md](.github/workflows/README.md). - The documentation site is easier for search engines to index. The home page is addressed as `/` everywhere, each page has its own meta description from its lead paragraph, the sitemap dates each page by its source's last commit, diff --git a/dkc/cli/github_ci.py b/dkc/cli/github_ci.py index b6f2684..3b1b1c0 100755 --- a/dkc/cli/github_ci.py +++ b/dkc/cli/github_ci.py @@ -12,7 +12,10 @@ export_image_bundle, export_lifecycle_outputs, export_source_environment, + kernel_build_required, prepare_pull_request_qualification, + pull_request_changed_paths, + require_pull_request_result, require_terminal_result, write_run_identity, write_workflow_assignments, @@ -90,6 +93,11 @@ def main() -> int: source.add_argument("--source", type=Path, required=True) images = subparsers.add_parser("export-image-bundle") images.add_argument("--input", type=Path, required=True) + pull_request = subparsers.add_parser("pull-request-result") + pull_request.add_argument("--fast-result", required=True) + pull_request.add_argument("--decision-result", required=True) + pull_request.add_argument("--build-required", required=True) + pull_request.add_argument("--build-result", required=True) terminal = subparsers.add_parser("terminal-result") terminal.add_argument("--decision", required=True) terminal.add_argument("--decision-result", required=True) @@ -156,6 +164,10 @@ def main() -> int: retention_max_bytes = int(args.retention_max_bytes) elif args.retention_max_bytes: raise ValueError("series retention does not accept a byte limit") + changed = pull_request_changed_paths(args.root) + build_required = kernel_build_required(changed) + if changed is None: + print("pull-request change set unavailable; building kernels") prepare_pull_request_qualification( args.source, args.decision, @@ -165,14 +177,26 @@ def main() -> int: lto_mode=cast(LtoMode, args.lto_mode), retention_mode=cast(RetentionMode, args.retention_mode), retention_max_bytes=retention_max_bytes, + build_required=build_required, ) - print("PASS pull-request build qualification prepared") + if build_required: + print("PASS pull-request build qualification prepared") + else: + print("PASS documentation-only pull request: kernel builds skipped") elif args.command == "export-source": export_source_environment(args.source, Path(required_environment("GITHUB_ENV"))) print("PASS authenticated source environment exported") elif args.command == "export-image-bundle": export_image_bundle(args.input, Path(required_environment("GITHUB_OUTPUT"))) print("PASS immutable image bundle outputs exported") + elif args.command == "pull-request-result": + require_pull_request_result( + fast_result=args.fast_result, + decision_result=args.decision_result, + build_required=args.build_required, + build_result=args.build_result, + ) + print("PASS every job the pull-request decision requires succeeded") elif args.command == "terminal-result": require_terminal_result( decision=args.decision, diff --git a/dkc/github.py b/dkc/github.py index 3b3c7ae..edd1ec7 100644 --- a/dkc/github.py +++ b/dkc/github.py @@ -4,6 +4,8 @@ import hashlib import re +import subprocess +from collections.abc import Sequence from datetime import UTC, datetime from pathlib import Path @@ -24,7 +26,10 @@ "export_lifecycle_outputs", "export_image_bundle", "export_source_environment", + "kernel_build_required", "prepare_pull_request_qualification", + "pull_request_changed_paths", + "require_pull_request_result", "require_terminal_result", "write_workflow_assignments", "write_run_identity", @@ -198,6 +203,74 @@ def export_lifecycle_outputs( write_workflow_assignments(output_file, values) +# A pull request that changes only these paths cannot change a kernel package, +# the APT repository, a container image, or the ci workflow, so its kernel +# builds are skipped. Each is documentation or the documentation site, which +# the site workflow's own required check builds and audits. Any other path, +# including a new site file, keeps the full build: the list fails closed. +_KERNEL_INDEPENDENT_PATHS = frozenset( + { + ".github/workflows/site.yml", + "dkc/cli/site_build.py", + "dkc/cli/site_indexnow.py", + "dkc/cli/site_publish.py", + "dkc/site.py", + "dkc/site_indexnow.py", + "dkc/site_publication.py", + "mk/site.mk", + "scripts/site-build.sh", + "scripts/site-publish.sh", + "tests/test_site.py", + "tests/test_site_indexnow.py", + "tests/test_site_publication.py", + } +) +_KERNEL_INDEPENDENT_PREFIXES = ("site/",) + + +def kernel_build_required(changed_paths: Sequence[str] | None) -> bool: + """Return whether a pull request with these changed paths must build kernels. + + An unknown (None) or empty change set requires the build. + """ + + if not changed_paths: + return True + return not all( + path.endswith(".md") + or path in _KERNEL_INDEPENDENT_PATHS + or path.startswith(_KERNEL_INDEPENDENT_PREFIXES) + for path in changed_paths + ) + + +def pull_request_changed_paths(repository_root: Path) -> tuple[str, ...] | None: + """Return the paths a pull request's merge commit changes, or None. + + GitHub checks out a pull request as a merge commit whose first parent is + the base branch. Anything else, or any Git failure, yields None so the + caller falls back to the full build. + """ + + def git(*arguments: str) -> str | None: + completed = subprocess.run( + ["git", "-C", str(repository_root), *arguments], + capture_output=True, + check=False, + ) + if completed.returncode != 0: + return None + return completed.stdout.decode("utf-8", errors="strict") + + parents = git("rev-list", "--parents", "-n", "1", "HEAD") + if parents is None or len(parents.split()) != 3: + return None + changed = git("diff", "--name-only", "--no-renames", "-z", "HEAD^1", "HEAD") + if changed is None: + return None + return tuple(path for path in changed.split("\0") if path) + + def prepare_pull_request_qualification( source_root: Path, decision_root: Path, @@ -208,6 +281,7 @@ def prepare_pull_request_qualification( lto_mode: LtoMode, retention_mode: RetentionMode, retention_max_bytes: int | None, + build_required: bool = True, ) -> None: """Create a build-only decision that can never authorize publication.""" @@ -231,8 +305,12 @@ def prepare_pull_request_qualification( retention_mode=retention_mode, retention_max_bytes=retention_max_bytes, utc=datetime.fromtimestamp(epoch, UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), - build_required=True, - reason="validate an untrusted pull request without publication authority", + build_required=build_required, + reason=( + "validate an untrusted pull request without publication authority" + if build_required + else "the pull request changes only documentation and the site" + ), ) write_discovery_decision(decision_root, decision) @@ -278,6 +356,28 @@ def export_image_bundle(bundle_file: Path, output_file: Path) -> None: write_workflow_assignments(output_file, values) +def require_pull_request_result( + *, fast_result: str, decision_result: str, build_required: str, build_result: str +) -> None: + """Accept a pull request only when every job its decision requires passed. + + The package matrix is the last kernel job; it succeeds only after every + earlier build job did. It may be skipped only when the decision, which + itself succeeded, did not require kernel builds. + """ + + if fast_result != "success": + raise ValueError("the fast tier did not succeed") + if decision_result != "success": + raise ValueError("the pull-request decision did not succeed") + expected = "success" if _boolean(build_required, "build_required") else "skipped" + if build_result != expected: + raise ValueError( + f"the kernel jobs ended with {build_result!r}; the decision requires" + f" {expected!r}" + ) + + def require_terminal_result( *, decision: str, decision_result: str, final_result: str ) -> None: diff --git a/dkc/records.py b/dkc/records.py index c1ef42e..1c88c19 100644 --- a/dkc/records.py +++ b/dkc/records.py @@ -195,6 +195,13 @@ def __post_init__(self) -> None: self.maintenance_required, self.publish_allowed, ) + if self.decision == "qualification" and observed_flags == ( + False, + False, + False, + ): + # A documentation-only pull request qualifies without a build. + expected_flags = observed_flags if observed_flags != expected_flags: raise ValueError( f"{self.decision} decision has a contradictory work/permission state" diff --git a/dkc/site.py b/dkc/site.py index c48ba4d..d4c9885 100644 --- a/dkc/site.py +++ b/dkc/site.py @@ -791,6 +791,13 @@ class _Document: title: list[str] = field(default_factory=list) text: list[str] = field(default_factory=list) source_component: bool = False + analytics: bool = False + consent_dialog: bool = False + + +# Material calls the analytics loader only behind this test of the stored +# consent. A theme change that drops it must fail the audit, not leak requests. +_ANALYTICS_CONSENT_GATE = "consent.analytics&&__md_analytics()" class _PageParser(HTMLParser): @@ -809,6 +816,10 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None self._in_title = True if values.get("data-md-component") == "source": self.document.source_component = True + if values.get("data-md-component") == "consent": + self.document.consent_dialog = True + if tag == "script" and values.get("id") == "__analytics": + self.document.analytics = True if tag == "meta" and values.get("name", "").lower() == "description": self.document.descriptions.append(values.get("content", "")) if tag == "link" and "canonical" in values.get("rel", "").split(): @@ -887,6 +898,11 @@ def audit_site( raise SiteError(f"{key}: empty title") if document.source_component: raise SiteError(f"{key}: repository widget would contact a third party") + if document.analytics and not ( + document.consent_dialog + and _ANALYTICS_CONSENT_GATE in "".join(document.text) + ): + raise SiteError(f"{key}: analytics would load without visitor consent") for tag, reference in document.references: checked += 1 _check_reference( diff --git a/docs/SITE.md b/docs/SITE.md index 4ca21e7..667569d 100644 --- a/docs/SITE.md +++ b/docs/SITE.md @@ -103,10 +103,24 @@ The build fails unless every check passes: - `sitemap.xml` lists exactly the pages, each once with a UTC `lastmod`; `robots.txt` matches the generated policy; `llms.txt` advertises only existing pages; and the IndexNow key file holds the tracked key; -- the home page shows the tracked archive primary fingerprint. +- the home page shows the tracked archive primary fingerprint; +- every page that carries the analytics loader also carries the consent dialog + and calls the loader only behind the stored analytics consent. -The site carries no analytics, web fonts, or other third-party requests. It is -not a trust anchor: users verify the archive key against the fingerprint +## Analytics and consent + +The site uses Google Analytics 4 through Material's built-in integration, +configured under `extra.analytics` in `site/mkdocs.yml`. Every visitor first +sees a cookie consent dialog with accept, reject, and manage actions; the +footer link reopens it. Until the visitor accepts analytics, the page loads +nothing from Google and sets no analytics cookie. The dialog appears for every +visitor, because the static site cannot tell where a visitor is. + +Google Analytics is the only third-party request. The site loads no web fonts, +repository widget, or other external resource, and the audit rejects any +external script, stylesheet, image, or frame in the generated HTML. + +The site is not a trust anchor: users verify the archive key against the fingerprint procedure in [USER_INSTALL.md](USER_INSTALL.md). ## Pull requests diff --git a/mk/github.mk b/mk/github.mk index d1eeb79..8030563 100644 --- a/mk/github.mk +++ b/mk/github.mk @@ -20,9 +20,11 @@ GITHUB_PR_APT_QUALIFICATION_OUTCOME ?= GITHUB_PR_APT_UNSIGNED_RESULT ?= $(DKC_ROOT)/out/apt-unsigned/$(DKC_RUN_ID) GITHUB_PR_APT_SIGNATURE_RESULT ?= $(DKC_ROOT)/out/apt-signature/$(DKC_RUN_ID) GITHUB_PR_APT_REPOSITORY_RESULT ?= $(DKC_ROOT)/out/apt-repository/$(DKC_RUN_ID) -# Comma-separated job results, as GitHub's join() renders needs.*.result. -GITHUB_NEEDS_RESULTS ?= -DKC_COMMA := , +# Job results and the decision output read by the pull-request aggregate check. +GITHUB_FAST_RESULT ?= +GITHUB_DECISION_RESULT ?= +GITHUB_BUILD_REQUIRED ?= +GITHUB_BUILD_RESULT ?= .PHONY: github-lifecycle-gate github-lifecycle-gate: ## Authorize one exact-main production lifecycle trigger @@ -176,12 +178,12 @@ github-release-cache-delete: ## Delete exact main-branch release caches after re --v3-key '$(GITHUB_RELEASE_CACHE_KEY_V3)' .PHONY: github-pull-request-result -github-pull-request-result: ## Require every direct dependency of an aggregate check to have succeeded - @test -n '$(strip $(GITHUB_NEEDS_RESULTS))' || { echo 'GITHUB_NEEDS_RESULTS is required'; exit 1; } - @for result in $(subst $(DKC_COMMA), ,$(GITHUB_NEEDS_RESULTS)); do \ - [ "$$result" = success ] || { echo "a required job ended with: $$result"; exit 1; }; \ - done - @echo 'PASS every required job succeeded' +github-pull-request-result: ## Require every job the pull-request decision selected to have succeeded + @$(DKC_CLI) dkc.cli.github_ci pull-request-result \ + --fast-result '$(GITHUB_FAST_RESULT)' \ + --decision-result '$(GITHUB_DECISION_RESULT)' \ + --build-required '$(GITHUB_BUILD_REQUIRED)' \ + --build-result '$(GITHUB_BUILD_RESULT)' .PHONY: github-lifecycle-result github-lifecycle-result: ## Require the workflow terminal state selected by discovery diff --git a/schemas/discovery-decision.schema.json b/schemas/discovery-decision.schema.json index 4c3d875..b6f8609 100644 --- a/schemas/discovery-decision.schema.json +++ b/schemas/discovery-decision.schema.json @@ -14,7 +14,7 @@ "properties": { "schema": { "const": "dkc.discovery-decision.v1" }, "decision": { - "description": "qualification requires a build but carries no state or publication authority; no_op and blocked are distinct because blocked means an invariant would be violated.", + "description": "qualification builds unless the pull request changes only documentation, and carries no state or publication authority; no_op and blocked are distinct because blocked means an invariant would be violated.", "enum": ["no_op", "build", "maintenance", "qualification", "blocked"] }, "reason": { "type": "string", "maxLength": 500 }, @@ -101,7 +101,6 @@ ] }, "properties": { - "build_required": { "const": true }, "maintenance_required": { "const": false }, "publish_allowed": { "const": false }, "authoritative_state_read": { "const": false } diff --git a/site/mkdocs.yml b/site/mkdocs.yml index eaebed8..a2052da 100644 --- a/site/mkdocs.yml +++ b/site/mkdocs.yml @@ -15,7 +15,7 @@ site_url: https://dkc-linux.romancello.net/ copyright: >- Project automation is MIT-licensed; Linux and Debian-derived packaging keep their own licenses. DKC is not sponsored, endorsed, or affiliated with the - Debian Project. + Debian Project. Change cookie settings docs_dir: pages use_directory_urls: false @@ -85,6 +85,21 @@ extra: - icon: fontawesome/brands/github link: https://github.com/kogeler/dkc-linux name: Source on GitHub + # Google Analytics loads only after the visitor accepts the consent dialog; + # the site audit rejects analytics without it. + analytics: + provider: google + property: G-LZ1273E7W8 + consent: + title: Cookie consent + description: >- + With your consent, this site uses Google Analytics cookies to count + visits and see which pages are read. Nothing is loaded from Google + unless you accept. + actions: + - accept + - reject + - manage nav: - Home: README.md diff --git a/tests/test_github.py b/tests/test_github.py index 2d1dca9..b828508 100644 --- a/tests/test_github.py +++ b/tests/test_github.py @@ -2,6 +2,7 @@ import hashlib import json +import subprocess from datetime import UTC, datetime from pathlib import Path @@ -12,7 +13,10 @@ export_image_bundle, export_lifecycle_outputs, export_source_environment, + kernel_build_required, prepare_pull_request_qualification, + pull_request_changed_paths, + require_pull_request_result, require_terminal_result, write_run_identity, write_workflow_assignments, @@ -190,6 +194,23 @@ def test_source_environment_is_reconstructed_from_the_typed_handoff( assert not decision.publish_allowed assert not decision.authoritative_state_read + documentation_root = tmp_path / "documentation-only" + prepare_pull_request_qualification( + root, + documentation_root, + repository_root=repository_root, + epoch=1_787_875_200, + dkc_revision=1, + lto_mode="thin", + retention_mode="series-size", + retention_max_bytes=9_500_000_000, + build_required=False, + ) + documentation = load_discovery_decision(documentation_root) + assert documentation.decision == "qualification" + assert not documentation.build_required + assert not documentation.publish_allowed + pull_request_output = tmp_path / "pull-request-output" export_lifecycle_outputs( decision_root, @@ -250,3 +271,100 @@ def test_terminal_result_accepts_only_the_selected_path( decision_result="success", final_result="failure", ) + + +@pytest.mark.parametrize( + ("paths", "required"), + [ + (("docs/SITE.md", "site/mkdocs.yml", "dkc/site.py"), False), + (("README.md", "tests/test_site.py", ".github/workflows/site.yml"), False), + (("docs/SITE.md", "dkc/github.py"), True), + (("site/mkdocs.yml", ".github/workflows/ci.yml"), True), + (("dkc/site_new.py",), True), + (("docs/notes.txt",), True), + ((), True), + (None, True), + ], +) +def test_only_documentation_changes_skip_kernel_builds( + paths: tuple[str, ...] | None, required: bool +) -> None: + assert kernel_build_required(paths) is required + + +def _git(root: Path, *arguments: str) -> None: + subprocess.run( + ["git", "-C", str(root), *arguments], + check=True, + capture_output=True, + env={ + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@example.invalid", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@example.invalid", + "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_CONFIG_NOSYSTEM": "1", + "HOME": str(root), + }, + ) + + +def test_the_change_set_is_read_from_a_pull_request_merge_commit( + tmp_path: Path, +) -> None: + _git(tmp_path, "init", "-q", "-b", "main") + (tmp_path / "base.txt").write_text("base\n", encoding="utf-8") + _git(tmp_path, "add", ".") + _git(tmp_path, "commit", "-q", "-m", "base") + assert pull_request_changed_paths(tmp_path) is None + + _git(tmp_path, "switch", "-q", "-c", "topic") + (tmp_path / "docs").mkdir() + (tmp_path / "docs/A.md").write_text("a\n", encoding="utf-8") + _git(tmp_path, "mv", "base.txt", "moved.txt") + _git(tmp_path, "add", ".") + _git(tmp_path, "commit", "-q", "-m", "topic") + _git(tmp_path, "switch", "-q", "main") + _git(tmp_path, "merge", "-q", "--no-ff", "-m", "merge", "topic") + + # A rename lists both names, so moving a build input out of the way counts. + assert pull_request_changed_paths(tmp_path) == ( + "base.txt", + "docs/A.md", + "moved.txt", + ) + assert pull_request_changed_paths(tmp_path / "missing") is None + + +@pytest.mark.parametrize( + ("build_required", "build_result", "accepted"), + [ + ("true", "success", True), + ("false", "skipped", True), + ("true", "skipped", False), + ("true", "failure", False), + ("false", "success", False), + ("", "skipped", False), + ], +) +def test_the_pull_request_result_follows_the_build_decision( + build_required: str, build_result: str, accepted: bool +) -> None: + def check(**overrides: str) -> None: + values = { + "fast_result": "success", + "decision_result": "success", + "build_required": build_required, + "build_result": build_result, + } + values.update(overrides) + require_pull_request_result(**values) + + if accepted: + check() + for name in ("fast_result", "decision_result"): + with pytest.raises(ValueError, match="did not succeed"): + check(**{name: "failure"}) + else: + with pytest.raises(ValueError): + check() diff --git a/tests/test_records.py b/tests/test_records.py index b7a504a..925f85a 100644 --- a/tests/test_records.py +++ b/tests/test_records.py @@ -198,7 +198,7 @@ def test_decision_matches_schema() -> None: schema.validate("discovery-decision", decision.to_dict()) -def test_qualification_requires_build_without_state_or_publication_authority() -> None: +def test_qualification_has_no_state_or_publication_authority() -> None: decision = DiscoveryDecision( decision="qualification", source_version="7.1.7-1", @@ -212,6 +212,17 @@ def test_qualification_requires_build_without_state_or_publication_authority() - schema.validate("discovery-decision", decision.to_dict()) assert not decision.authoritative_state_read assert not decision.publish_allowed + documentation_only = DiscoveryDecision( + decision="qualification", + source_version="7.1.7-1", + source_dsc_sha256=SHA, + utc=NOW, + dkc_revision=1, + build_policy_sha256="c" * 64, + lto_mode="thin", + build_required=False, + ) + schema.validate("discovery-decision", documentation_only.to_dict()) with pytest.raises(ValueError, match="contradictory"): DiscoveryDecision( decision="qualification", @@ -222,6 +233,7 @@ def test_qualification_requires_build_without_state_or_publication_authority() - build_policy_sha256="c" * 64, lto_mode="thin", build_required=False, + maintenance_required=True, ) with pytest.raises(ValueError, match="cannot carry authoritative state"): DiscoveryDecision( diff --git a/tests/test_site.py b/tests/test_site.py index b43dfe1..546b26a 100644 --- a/tests/test_site.py +++ b/tests/test_site.py @@ -463,6 +463,36 @@ def test_the_audit_rejects_each_contract_violation( _audit(site, pages) +_ANALYTICS = '' +_CONSENT_DIALOG = '
' +_CONSENT_GATE = ( + '" +) + + +@pytest.mark.parametrize( + ("addition", "accepted"), + [ + (_ANALYTICS + _CONSENT_DIALOG + _CONSENT_GATE, True), + (_ANALYTICS + _CONSENT_GATE, False), + (_ANALYTICS + _CONSENT_DIALOG + "", False), + ], +) +def test_analytics_must_wait_for_visitor_consent( + tmp_path: Path, addition: str, accepted: bool +) -> None: + site, pages = _site(tmp_path) + target = site / "docs/A.html" + text = target.read_text(encoding="utf-8") + target.write_text(text.replace("