From ab76d90d5e39d31c4f0d4bcbbcfb10b255e3ed76 Mon Sep 17 00:00:00 2001 From: UnArbosFive Date: Mon, 27 Jul 2026 15:33:35 +0200 Subject: [PATCH] ci: harden docs preview lifecycle --- .github/scripts/docs_preview_github.py | 4 +- .github/scripts/docs_preview_vercel.py | 84 +++++++- .github/scripts/test_docs_preview_github.py | 7 +- .github/scripts/test_docs_preview_vercel.py | 180 ++++++++++++++++-- .../scripts/test_docs_preview_workflows.py | 39 +++- .../workflows/check-docs-preview-security.yml | 2 + .github/workflows/deploy-docs-preview.yml | 11 +- .github/workflows/request-docs-preview.yml | 59 ++++-- website/apps/bittensor-website/vercel.json | 9 + 9 files changed, 352 insertions(+), 43 deletions(-) create mode 100644 website/apps/bittensor-website/vercel.json diff --git a/.github/scripts/docs_preview_github.py b/.github/scripts/docs_preview_github.py index 1a21da10cd..301e3ef72d 100644 --- a/.github/scripts/docs_preview_github.py +++ b/.github/scripts/docs_preview_github.py @@ -250,10 +250,8 @@ def reconcile_mode( raise ValueError("invalid docs-preview action") if pull.state == "closed": return "cleanup" - if action == "cleanup": - return "noop" if pull.head_sha == expected_head_sha and pull.head_repository == repository: - return "deploy" + return action return "noop" diff --git a/.github/scripts/docs_preview_vercel.py b/.github/scripts/docs_preview_vercel.py index 420c0b38e6..9685e4a7ee 100644 --- a/.github/scripts/docs_preview_vercel.py +++ b/.github/scripts/docs_preview_vercel.py @@ -22,6 +22,13 @@ DOMAIN_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$") DEPLOYMENT_ID_PATTERN = re.compile(r"^dpl_[A-Za-z0-9]+$") DEPLOYMENT_URL_PATTERN = re.compile(r"^https://[A-Za-z0-9.-]+$") +BUILD_ROOT_DIRECTORY = "website/apps/bittensor-website" +SENSITIVE_SYSTEM_VARIABLES = frozenset( + { + "VERCEL_AUTOMATION_BYPASS_SECRET", + "VERCEL_OIDC_TOKEN", + } +) class ApiError(RuntimeError): @@ -161,7 +168,7 @@ def write_project_link( "installCommand": None, "buildCommand": None, "outputDirectory": None, - "rootDirectory": "website/apps/bittensor-website", + "rootDirectory": BUILD_ROOT_DIRECTORY, "directoryListing": False, "nodeVersion": "24.x", } @@ -209,6 +216,14 @@ def unsafe_preview_variables( unsafe.append(UnsafeEnvironmentVariable(source, "")) continue if variable.get("system") is True: + key = variable.get("key") + if not isinstance(key, str) or key in SENSITIVE_SYSTEM_VARIABLES: + unsafe.append( + UnsafeEnvironmentVariable( + source, + key if isinstance(key, str) and key else "", + ) + ) continue if _targets_preview(variable.get("target")): key = variable.get("key") @@ -221,13 +236,50 @@ def unsafe_preview_variables( return sorted(unsafe, key=lambda item: (item.source, item.key)) +def validate_project_security_settings(project: object, project_id: str) -> str: + if not isinstance(project, dict) or project.get("id") != project_id: + raise ApiError("Vercel returned the wrong docs-preview project") + if project.get("autoExposeSystemEnvs") is not False: + raise ApiError( + "docs-preview project must disable automatic system environment variables" + ) + + oidc = project.get("oidcTokenConfig") + if oidc is not None and ( + not isinstance(oidc, dict) or oidc.get("enabled") is not False + ): + raise ApiError("docs-preview project must disable OIDC token generation") + + protection_bypass = project.get("protectionBypass") + if protection_bypass not in (None, {}): + raise ApiError("docs-preview project must not configure a protection bypass") + + integrations = project.get("integrations") + if integrations not in (None, []): + raise ApiError("docs-preview project must not attach integrations or resources") + + root_directory = project.get("rootDirectory") + if root_directory is None: + return "." + if root_directory != BUILD_ROOT_DIRECTORY: + raise ApiError( + "docs-preview project rootDirectory must be empty or " + f"{BUILD_ROOT_DIRECTORY}" + ) + return root_directory + + def audit_project( client: VercelClient, project_id: str, preview_domain: str, -) -> None: +) -> str: + quoted_project_id = urllib.parse.quote(project_id, safe="") + project = client.request("GET", f"/v9/projects/{quoted_project_id}") + root_directory = validate_project_security_settings(project, project_id) + project_path = "/v10/projects/{}/env".format( - urllib.parse.quote(project_id, safe="") + quoted_project_id ) project_variables = client.paginated(project_path, "envs", {"decrypt": "false"}) shared_variables = client.paginated( @@ -258,6 +310,7 @@ def audit_project( raise ApiError( f"preview project must pre-provision and verify {preview_domain}" ) + return root_directory def deployment_id_for_url( @@ -311,10 +364,16 @@ def remove_alias( ) if response is None: return False - alias_id = response.get("uid") if isinstance(response, dict) else None - alias_project_id = response.get("projectId") if isinstance(response, dict) else None - if not isinstance(alias_id, str) or not alias_id or alias_project_id != project_id: - raise ApiError("refusing to remove an alias outside the preview project") + if not isinstance(response, dict): + raise ApiError("Vercel returned an invalid alias response") + alias_project_id = response.get("projectId") + if not isinstance(alias_project_id, str) or not alias_project_id: + raise ApiError("Vercel alias response has no project ID") + if alias_project_id != project_id: + return False + alias_id = response.get("uid") + if not isinstance(alias_id, str) or not alias_id: + raise ApiError("Vercel alias response has no alias ID") client.request( "DELETE", f"/now/aliases/{urllib.parse.quote(alias_id, safe='')}", @@ -360,7 +419,7 @@ def delete_pr_deployments( keep_deployment_id: Optional[str] = None, ) -> int: deployments = client.paginated( - "/v7/deployments", + "/v6/deployments", "deployments", {"projectId": project_id}, ) @@ -453,7 +512,12 @@ def main(arguments: Optional[Iterable[str]] = None) -> int: raise ValueError("VERCEL_TOKEN is required") client = VercelClient(token, args.team_id) if args.command == "audit-project": - audit_project(client, args.project_id, args.preview_domain) + root_directory = audit_project( + client, + args.project_id, + args.preview_domain, + ) + _write_output("root_directory", root_directory) print("Vercel preview project and domain satisfy the trusted boundary") elif args.command == "deployment-id": deployment_id = deployment_id_for_url( @@ -470,7 +534,7 @@ def main(arguments: Optional[Iterable[str]] = None) -> int: print( "Removed preview alias" if removed - else "Preview alias was already absent" + else "No preview-project alias needed removal" ) elif args.command == "delete-pr-deployments": count = delete_pr_deployments( diff --git a/.github/scripts/test_docs_preview_github.py b/.github/scripts/test_docs_preview_github.py index 0096d058ba..c30f4e0345 100644 --- a/.github/scripts/test_docs_preview_github.py +++ b/.github/scripts/test_docs_preview_github.py @@ -76,7 +76,12 @@ def test_reconciliation_fails_closed_for_stale_or_foreign_heads(self): self.assertEqual(reconcile_mode("deploy", stale, SHA, REPOSITORY), "noop") self.assertEqual(reconcile_mode("deploy", foreign, SHA, REPOSITORY), "noop") self.assertEqual(reconcile_mode("deploy", closed, SHA, REPOSITORY), "cleanup") - self.assertEqual(reconcile_mode("cleanup", matching, SHA, REPOSITORY), "noop") + self.assertEqual( + reconcile_mode("cleanup", matching, SHA, REPOSITORY), + "cleanup", + ) + self.assertEqual(reconcile_mode("cleanup", stale, SHA, REPOSITORY), "noop") + self.assertEqual(reconcile_mode("cleanup", foreign, SHA, REPOSITORY), "noop") def test_prepare_validates_artifact_and_emits_bounded_outputs(self): responses = [ diff --git a/.github/scripts/test_docs_preview_vercel.py b/.github/scripts/test_docs_preview_vercel.py index 6c31c84a6a..e363027a43 100644 --- a/.github/scripts/test_docs_preview_vercel.py +++ b/.github/scripts/test_docs_preview_vercel.py @@ -1,22 +1,27 @@ #!/usr/bin/env python3 import json +import os import tempfile import unittest from pathlib import Path -from unittest.mock import Mock +from unittest.mock import Mock, patch from docs_preview_vercel import ( ApiError, + BUILD_ROOT_DIRECTORY, UnsafeEnvironmentVariable, VercelClient, audit_project, + delete_pr_deployments, deployment_id_for_url, deployment_ids_for_pr, + main, remove_alias, set_alias, unsafe_preview_variables, validate_configuration, + validate_project_security_settings, write_project_link, ) @@ -40,6 +45,17 @@ def request( class DocsPreviewVercelTests(unittest.TestCase): + @staticmethod + def secure_project(root_directory=BUILD_ROOT_DIRECTORY): + return { + "id": "prj_preview", + "autoExposeSystemEnvs": False, + "oidcTokenConfig": {"enabled": False, "issuerMode": "team"}, + "protectionBypass": {}, + "integrations": [], + "rootDirectory": root_directory, + } + def test_flags_project_and_shared_preview_variables_without_values(self): unsafe = unsafe_preview_variables( [ @@ -50,6 +66,11 @@ def test_flags_project_and_shared_preview_variables_without_values(self): }, {"key": "PRODUCTION_ONLY", "target": ["production"]}, {"key": "VERCEL_ENV", "target": ["preview"], "system": True}, + { + "key": "VERCEL_OIDC_TOKEN", + "target": ["preview"], + "system": True, + }, ], [ {"key": "SHARED_SECRET", "target": "preview"}, @@ -60,6 +81,7 @@ def test_flags_project_and_shared_preview_variables_without_values(self): unsafe, [ UnsafeEnvironmentVariable("project", "PROJECT_SECRET"), + UnsafeEnvironmentVariable("project", "VERCEL_OIDC_TOKEN"), UnsafeEnvironmentVariable("team-shared", "SHARED_SECRET"), ], ) @@ -78,11 +100,21 @@ def test_unknown_environment_shapes_fail_closed(self): def test_project_audit_covers_environment_and_verified_wildcard(self): client = Mock() client.paginated.side_effect = [[], []] - client.request.return_value = { - "name": "*.preview.bittensor.com", - "verified": True, - } - audit_project(client, "prj_preview", "*.preview.bittensor.com") + client.request.side_effect = [ + self.secure_project(), + { + "name": "*.preview.bittensor.com", + "verified": True, + }, + ] + self.assertEqual( + audit_project(client, "prj_preview", "*.preview.bittensor.com"), + BUILD_ROOT_DIRECTORY, + ) + self.assertEqual( + client.request.call_args_list[0].args, + ("GET", "/v9/projects/prj_preview"), + ) self.assertEqual( client.paginated.call_args_list[0].args, ("/v10/projects/prj_preview/env", "envs", {"decrypt": "false"}), @@ -92,7 +124,7 @@ def test_project_audit_covers_environment_and_verified_wildcard(self): ("/v1/env", "data", {"projectId": "prj_preview"}), ) self.assertEqual( - client.request.call_args.args, + client.request.call_args_list[1].args, ( "GET", "/v9/projects/prj_preview/domains/%2A.preview.bittensor.com", @@ -101,6 +133,7 @@ def test_project_audit_covers_environment_and_verified_wildcard(self): def test_project_audit_rejects_secrets_without_logging_values(self): client = Mock() + client.request.return_value = self.secure_project() client.paginated.side_effect = [ [{"key": "SECRET_VALUE", "value": "do-not-log", "target": ["preview"]}], [], @@ -109,7 +142,7 @@ def test_project_audit_rejects_secrets_without_logging_values(self): audit_project(client, "prj_preview", "*.preview.bittensor.com") self.assertIn("SECRET_VALUE", str(context.exception)) self.assertNotIn("do-not-log", str(context.exception)) - client.request.assert_not_called() + self.assertEqual(client.request.call_count, 1) def test_project_audit_rejects_missing_or_unverified_wildcard(self): for response in ( @@ -120,7 +153,7 @@ def test_project_audit_rejects_missing_or_unverified_wildcard(self): with self.subTest(response=response): client = Mock() client.paginated.side_effect = [[], []] - client.request.return_value = response + client.request.side_effect = [self.secure_project(), response] with self.assertRaises(ApiError): audit_project( client, @@ -128,6 +161,92 @@ def test_project_audit_rejects_missing_or_unverified_wildcard(self): "*.preview.bittensor.com", ) + def test_project_security_settings_fail_closed_on_credentials_and_links(self): + unsafe_projects = ( + {**self.secure_project(), "autoExposeSystemEnvs": True}, + { + **self.secure_project(), + "oidcTokenConfig": {"enabled": True, "issuerMode": "team"}, + }, + { + **self.secure_project(), + "protectionBypass": { + "secret": {"scope": "automation-bypass", "isEnvVar": True} + }, + }, + { + **self.secure_project(), + "integrations": [ + {"installationId": "icfg_123", "resources": []} + ], + }, + {**self.secure_project(), "rootDirectory": "other/application"}, + {**self.secure_project(), "id": "prj_production"}, + ) + for project in unsafe_projects: + with self.subTest(project=project): + with self.assertRaises(ApiError): + validate_project_security_settings(project, "prj_preview") + + missing_system_policy = self.secure_project() + del missing_system_policy["autoExposeSystemEnvs"] + with self.assertRaises(ApiError): + validate_project_security_settings( + missing_system_policy, + "prj_preview", + ) + + def test_project_security_settings_accept_an_empty_remote_root(self): + self.assertEqual( + validate_project_security_settings( + self.secure_project(root_directory=None), + "prj_preview", + ), + ".", + ) + + @patch("docs_preview_vercel.VercelClient") + def test_audit_command_exports_only_the_validated_root_directory( + self, + client_type, + ): + client = client_type.return_value + client.request.side_effect = [ + self.secure_project(), + { + "name": "*.preview.bittensor.com", + "verified": True, + }, + ] + client.paginated.side_effect = [[], []] + with tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) / "github-output" + with patch.dict( + os.environ, + { + "GITHUB_OUTPUT": str(output), + "VERCEL_TOKEN": "token", + }, + clear=False, + ): + result = main( + [ + "--project-id", + "prj_preview", + "--team-id", + "team_123", + "audit-project", + "--preview-domain", + "*.preview.bittensor.com", + ] + ) + exported = output.read_text(encoding="utf-8") + self.assertEqual(result, 0) + self.assertEqual( + exported, + f"root_directory={BUILD_ROOT_DIRECTORY}\n", + ) + def test_configuration_requires_distinct_valid_projects(self): validate_configuration( "team_123", @@ -161,7 +280,7 @@ def test_project_link_contains_only_expected_identifiers_and_settings(self): self.assertEqual(payload["projectId"], "prj_preview") self.assertEqual( payload["settings"]["rootDirectory"], - "website/apps/bittensor-website", + BUILD_ROOT_DIRECTORY, ) def test_pagination_collects_every_page_and_rejects_cursor_loops(self): @@ -236,14 +355,24 @@ def test_alias_removal_is_confined_to_preview_project(self): wrong_project = FakeClient( [{"uid": "alias_123", "projectId": "prj_production"}] ) - with self.assertRaises(ApiError): + self.assertFalse( remove_alias( wrong_project, "prj_preview", "pr-42.preview.bittensor.com", ) + ) self.assertEqual(len(wrong_project.calls), 1) + malformed = FakeClient([{"projectId": "prj_preview"}]) + with self.assertRaises(ApiError): + remove_alias( + malformed, + "prj_preview", + "pr-42.preview.bittensor.com", + ) + self.assertEqual(len(malformed.calls), 1) + def test_selects_only_matching_obsolete_deployments(self): deployments = [ {"uid": "keep", "meta": {"docsPreviewPr": "42"}}, @@ -256,6 +385,35 @@ def test_selects_only_matching_obsolete_deployments(self): ["old"], ) + def test_cleanup_uses_documented_list_and_delete_endpoints(self): + client = FakeClient( + [ + { + "deployments": [ + {"uid": "dpl_old123", "meta": {"docsPreviewPr": "42"}} + ], + "pagination": {"next": None}, + }, + {}, + ] + ) + self.assertEqual( + delete_pr_deployments(client, "prj_preview", "42"), + 1, + ) + self.assertEqual( + client.calls[0][0:3], + ( + "GET", + "/v6/deployments", + {"projectId": "prj_preview", "limit": 100}, + ), + ) + self.assertEqual( + client.calls[1][0:2], + ("DELETE", "/v13/deployments/dpl_old123"), + ) + def test_invalid_deployment_response_fails_closed(self): with self.assertRaises(ApiError): deployment_ids_for_pr([None], "42") diff --git a/.github/scripts/test_docs_preview_workflows.py b/.github/scripts/test_docs_preview_workflows.py index 7438eb737e..db5c095132 100644 --- a/.github/scripts/test_docs_preview_workflows.py +++ b/.github/scripts/test_docs_preview_workflows.py @@ -15,6 +15,9 @@ VERCEL_SCRIPT = REPOSITORY / ".github/scripts/docs_preview_vercel.py" CLI_PACKAGE = REPOSITORY / ".github/docs-preview-vercel/package.json" CLI_LOCK = REPOSITORY / ".github/docs-preview-vercel/package-lock.json" +PRODUCTION_VERCEL_CONFIG = ( + REPOSITORY / "website/apps/bittensor-website/vercel.json" +) ACTION_SHA = re.compile(r"^\s*uses:\s+[^#\s]+@[0-9a-f]{40}(?:\s+#.*)?$") @@ -42,14 +45,15 @@ def test_every_external_action_is_pinned_to_a_commit(self): def test_untrusted_workflow_is_secret_free_and_ephemeral(self): self.assertNotIn("secrets.", self.request) self.assertNotIn("self-hosted", self.request) - self.assertGreaterEqual(self.request.count("runs-on: ubuntu-24.04"), 2) + self.assertEqual(self.request.count("runs-on: ubuntu-24.04"), 3) + self.assertIn("retention-days: 1", self.request) self.assertIn("persist-credentials: false", self.request) self.assertIn("cancel-in-progress: true", self.request) - self.assertIn("retention-days: 1", self.request) def test_trusted_workflow_never_checks_out_pr_head(self): self.assertNotIn("pull_request.head.sha", self.deploy) - self.assertIn("ref: ${{ github.event.repository.default_branch }}", self.deploy) + self.assertIn("ref: ${{ github.sha }}", self.deploy) + self.assertNotIn("ref: ${{ github.event.repository.default_branch }}", self.deploy) self.assertIn("persist-credentials: false", self.deploy) self.assertNotIn("self-hosted", self.deploy) self.assertIn("runs-on: ubuntu-24.04", self.deploy) @@ -64,6 +68,13 @@ def test_trusted_workflow_only_uses_vercel_deploy_token(self): r"(?m)^\s{4}env:\s*\n(?:\s{6}.+\n)*\s{6}\w+:\s*\$\{\{\s*secrets\.", ) + def test_production_project_git_deploys_only_main(self): + config = json.loads(PRODUCTION_VERCEL_CONFIG.read_text(encoding="utf-8")) + self.assertEqual( + config["git"]["deploymentEnabled"], + {"*": False, "main": True}, + ) + def test_dedicated_preview_project_is_enforced_in_both_halves(self): for workflow in (self.deploy, self.request): self.assertIn("VERCEL_DOCS_PREVIEW_PROJECT_ID", workflow) @@ -76,10 +87,23 @@ def test_dedicated_preview_project_is_enforced_in_both_halves(self): self.assertIn('"non-production project"', self.vercel) self.assertNotIn("secrets.VERCEL_DOCS_PROJECT_ID", self.deploy) self.assertIn("vars.VERCEL_DOCS_PREVIEW_PROJECT_ID != ''", self.deploy) - self.assertGreaterEqual( + self.assertEqual( self.request.count("vars.VERCEL_DOCS_PREVIEW_PROJECT_ID != ''"), - 2, + 3, + ) + + def test_close_cleanup_is_unfiltered_and_triggers_trusted_consumer(self): + self.assertIn("types: [opened, synchronize, reopened, closed]", self.request) + self.assertNotIn("paths:", self.request) + self.assertIn("github.event.action == 'closed'", self.request) + self.assertIn("always() &&", self.request) + self.assertIn("needs.changes.outputs.relevant == 'false'", self.request) + self.assertIn("actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd", self.request) + self.assertIn( + "group: request-docs-preview-${{ github.event.pull_request.number }}", + self.request, ) + self.assertIn('workflows: ["Request Docs Preview"]', self.deploy) def test_deploy_queue_and_stale_run_controls_are_present(self): self.assertIn("cancel-in-progress: false", self.deploy) @@ -121,6 +145,9 @@ def test_bundle_is_self_contained_and_validates_every_vercel_file_map(self): def test_preview_environment_and_deployment_lifecycle_are_guarded(self): self.assertIn("audit-project", self.deploy) + self.assertIn("id: project", self.deploy) + self.assertIn("steps.project.outputs.root_directory", self.deploy) + self.assertIn('mkdir -p -- "${DEPLOY_ROOT}/${VERCEL_ROOT_DIRECTORY}"', self.deploy) self.assertIn('--preview-domain "*.preview.${DOCS_DOMAIN}"', self.deploy) self.assertIn( 'client.paginated(project_path, "envs", {"decrypt": "false"})', self.vercel @@ -151,6 +178,8 @@ def test_cli_graph_is_locked_and_installed_without_scripts(self): "57.0.0", ) self.assertIn("tar", package["overrides"]) + self.assertEqual(package["overrides"]["brace-expansion"], "5.0.8") + self.assertEqual(package["overrides"]["@tootallnate/once"], "3.0.1") for workflow in (self.deploy, self.request): self.assertIn("npm ci --ignore-scripts", workflow) self.assertIn("npm audit --audit-level=high", workflow) diff --git a/.github/workflows/check-docs-preview-security.yml b/.github/workflows/check-docs-preview-security.yml index a4d31e02b2..2c5868b327 100644 --- a/.github/workflows/check-docs-preview-security.yml +++ b/.github/workflows/check-docs-preview-security.yml @@ -9,6 +9,7 @@ on: - ".github/workflows/check-docs-preview-security.yml" - ".github/workflows/deploy-docs-preview.yml" - ".github/workflows/request-docs-preview.yml" + - "website/apps/bittensor-website/vercel.json" push: branches: - main @@ -20,6 +21,7 @@ on: - ".github/workflows/check-docs-preview-security.yml" - ".github/workflows/deploy-docs-preview.yml" - ".github/workflows/request-docs-preview.yml" + - "website/apps/bittensor-website/vercel.json" permissions: contents: read diff --git a/.github/workflows/deploy-docs-preview.yml b/.github/workflows/deploy-docs-preview.yml index f0185260c8..5ade880b9f 100644 --- a/.github/workflows/deploy-docs-preview.yml +++ b/.github/workflows/deploy-docs-preview.yml @@ -30,6 +30,7 @@ jobs: github.event.workflow_run.head_repository.full_name == github.repository && vars.VERCEL_DOCS_PREVIEW_PROJECT_ID != '' runs-on: ubuntu-24.04 + timeout-minutes: 45 env: DOCS_DOMAIN: ${{ vars.DOCS_DOMAIN || 'bittensor.com' }} VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} @@ -39,7 +40,7 @@ jobs: - name: Check out trusted controls from the default branch uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - ref: ${{ github.event.repository.default_branch }} + ref: ${{ github.sha }} persist-credentials: false path: trusted-source sparse-checkout: | @@ -108,6 +109,7 @@ jobs: - name: Audit preview project and pre-provisioned domain if: steps.meta.outputs.mode == 'deploy' + id: project env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} run: | @@ -135,7 +137,14 @@ jobs: if: steps.recheck.outputs.authorized == 'true' env: DEPLOY_ROOT: ${{ steps.extract.outputs.root }} + VERCEL_ROOT_DIRECTORY: ${{ steps.project.outputs.root_directory }} run: | + set -euo pipefail + case "${VERCEL_ROOT_DIRECTORY}" in + .|website/apps/bittensor-website) ;; + *) echo "invalid audited Vercel root directory" >&2; exit 1 ;; + esac + mkdir -p -- "${DEPLOY_ROOT}/${VERCEL_ROOT_DIRECTORY}" python3 trusted-source/.github/scripts/docs_preview_vercel.py \ --project-id "${VERCEL_PREVIEW_PROJECT_ID}" \ --team-id "${VERCEL_ORG_ID}" \ diff --git a/.github/workflows/request-docs-preview.yml b/.github/workflows/request-docs-preview.yml index c4da997467..b852da9628 100644 --- a/.github/workflows/request-docs-preview.yml +++ b/.github/workflows/request-docs-preview.yml @@ -1,8 +1,8 @@ name: Request Docs Preview # Untrusted half of docs previews. This workflow runs pull-request code, so it -# must remain secret-free. It only builds and uploads a bounded-lifetime -# artifact for the default-branch workflow to validate. +# must remain secret-free. It builds or requests reconciliation and uploads a +# bounded-lifetime artifact for the default-branch workflow to validate. # # Repository variables used when previews are enabled (public identifiers, not # credentials). The jobs stay disabled until the dedicated preview ID exists: @@ -13,15 +13,10 @@ name: Request Docs Preview on: pull_request: types: [opened, synchronize, reopened, closed] - paths: - - "docs/**" - - "website/**" - - ".github/docs-preview-vercel/**" - - ".github/scripts/docs_preview_*.py" - - ".github/workflows/request-docs-preview.yml" permissions: contents: read + pull-requests: read # A close or newer commit cancels an in-flight build for the same PR. concurrency: @@ -29,14 +24,49 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: Detect docs-preview changes + if: > + github.event.action != 'closed' && + !github.event.pull_request.head.repo.fork && + github.event.pull_request.head.repo.full_name == github.repository && + vars.VERCEL_DOCS_PREVIEW_PROJECT_ID != '' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + relevant: ${{ steps.paths.outputs.result }} + steps: + - name: Inspect the complete pull-request file list + id: paths + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + result-encoding: string + script: | + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + per_page: 100, + }); + const relevant = files.length >= 3000 || files.some(({ filename }) => + filename.startsWith("docs/") || + filename.startsWith("website/") || + filename.startsWith(".github/docs-preview-vercel/") || + /^\.github\/scripts\/docs_preview_.*\.py$/.test(filename) || + filename === ".github/workflows/request-docs-preview.yml" + ); + return relevant ? "true" : "false"; + build: name: Build PR docs preview (no secrets) + needs: changes if: > - github.event.action != 'closed' && + needs.changes.outputs.relevant == 'true' && !github.event.pull_request.head.repo.fork && github.event.pull_request.head.repo.full_name == github.repository && vars.VERCEL_DOCS_PREVIEW_PROJECT_ID != '' runs-on: ubuntu-24.04 + timeout-minutes: 30 env: DOCS_DOMAIN: ${{ vars.DOCS_DOMAIN || 'bittensor.com' }} VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} @@ -114,13 +144,18 @@ jobs: retention-days: 1 request-cleanup: - name: Request preview cleanup (no secrets) - if: > - github.event.action == 'closed' && + name: Reconcile an obsolete or closed preview (no secrets) + needs: changes + if: >- + always() && + (github.event.action == 'closed' || + (needs.changes.result == 'success' && + needs.changes.outputs.relevant == 'false')) && !github.event.pull_request.head.repo.fork && github.event.pull_request.head.repo.full_name == github.repository && vars.VERCEL_DOCS_PREVIEW_PROJECT_ID != '' runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - name: Create cleanup marker env: diff --git a/website/apps/bittensor-website/vercel.json b/website/apps/bittensor-website/vercel.json new file mode 100644 index 0000000000..2712c7a3d2 --- /dev/null +++ b/website/apps/bittensor-website/vercel.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "git": { + "deploymentEnabled": { + "*": false, + "main": true + } + } +}