From 7532d3e7b6724e10ab2944bd491800e45434991a Mon Sep 17 00:00:00 2001 From: "lin.si" Date: Mon, 27 Jul 2026 11:31:35 +0800 Subject: [PATCH] fix(docs): publish production files at bucket root --- .github/workflows/deploy-oss.yml | 4 ++- DEPLOYMENT.md | 16 +++++++---- scripts/deploy_docs_to_oss.py | 44 +++++++++++++++++------------- scripts/test_deploy_docs_to_oss.py | 16 +++++++++-- 4 files changed, 52 insertions(+), 28 deletions(-) diff --git a/.github/workflows/deploy-oss.yml b/.github/workflows/deploy-oss.yml index 1a6ee31..aed4534 100644 --- a/.github/workflows/deploy-oss.yml +++ b/.github/workflows/deploy-oss.yml @@ -73,7 +73,7 @@ jobs: path: dist - name: Deploy production docs - run: python3 scripts/deploy_docs_to_oss.py --target-env prod + run: python3 scripts/deploy_docs_to_oss.py - name: Verify public docs run: | @@ -86,3 +86,5 @@ jobs: curl --fail --silent --show-error "$base_url/openapi/calle.openapi.yaml" --output /tmp/calle-docs-openapi.yaml test -s /tmp/calle-docs-index.html test -s /tmp/calle-docs-openapi.yaml + cmp --silent dist/index.html /tmp/calle-docs-index.html + cmp --silent dist/openapi/calle.openapi.yaml /tmp/calle-docs-openapi.yaml diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 24b8882..aee5bea 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -29,8 +29,10 @@ Add these environment variables: | `DOCS_PUBLIC_URL` | `https://docs.example.com` | CDN or gateway URL used by the post-deploy smoke check. | The deployment identity should have only the OSS permissions needed to list the -configured bucket prefix and read or write docs objects. Do not reuse broad -developer credentials. +bucket and read or write the root docs objects. The bucket also contains +unrelated namespaced applications such as `dashboard/`; the workflow never +deletes objects and does not modify those prefixes. Do not reuse broad developer +credentials. ## Workflow @@ -41,11 +43,12 @@ The deployment workflow runs after a push to `main` or a manual dispatch: 3. Upload the generated `dist/` directory as a short-lived workflow artifact. 4. Start the `production` environment job. 5. Download the exact artifact built by the validation job. -6. Upload it to `calle-docs-site/prod/` in OSS. +6. Upload it to the OSS bucket root used by `docs.heycall-e.com`. 7. Verify signed reads of `index.html` and `openapi/calle.openapi.yaml`. 8. List the destination prefix and confirm every build object exists. -9. Fetch the entry page and OpenAPI document through `DOCS_PUBLIC_URL`. +9. Fetch the entry page and OpenAPI document through `DOCS_PUBLIC_URL`, then + compare both files byte-for-byte with the build artifact. The build job does not reference deployment secrets. Pull requests from forks only run CI and cannot access the `production` environment. @@ -88,8 +91,9 @@ The site uses hash routing, so routes such as `#/quickstart` and With the four OSS environment variables set locally: ```bash -python3 scripts/deploy_docs_to_oss.py --dry-run --target-env prod +python3 scripts/deploy_docs_to_oss.py --dry-run ``` The dry run prints the destination and file plan but never prints credential -values. +values. Pass `--deploy-prefix calle-docs-site/test` only for an explicitly +configured non-production origin prefix. diff --git a/scripts/deploy_docs_to_oss.py b/scripts/deploy_docs_to_oss.py index d1cdcdb..03231f3 100644 --- a/scripts/deploy_docs_to_oss.py +++ b/scripts/deploy_docs_to_oss.py @@ -42,11 +42,27 @@ def content_type_for(path: Path) -> str: def cache_control_for(key: str) -> str: - if "/assets/" in key: + if key.startswith("assets/") or "/assets/" in key: return "public, max-age=31536000, immutable" return "no-cache" +def normalize_prefix(prefix: str) -> str: + normalized = prefix.strip("/") + if not normalized: + return "" + + segment_pattern = re.compile(r"[a-z0-9][a-z0-9_-]*") + if any( + not segment_pattern.fullmatch(segment) + for segment in normalized.split("/") + ): + raise ValueError( + "deploy prefix must contain lowercase path segments" + ) + return f"{normalized}/" + + def collect_files(dist_dir: Path) -> list[Path]: if not dist_dir.exists(): raise FileNotFoundError(f"dist dir does not exist: {dist_dir}") @@ -173,14 +189,9 @@ def build_parser() -> argparse.ArgumentParser: help="Built docs-site dist directory.", ) parser.add_argument( - "--site-prefix", - default="calle-docs-site", - help="Root OSS prefix for the docs site.", - ) - parser.add_argument( - "--target-env", - default="prod", - help="Deployment environment segment.", + "--deploy-prefix", + default="", + help="Optional OSS object prefix. Production deploys to the bucket root.", ) parser.add_argument( "--dry-run", @@ -210,18 +221,13 @@ def main() -> int: ) return 2 - site_prefix = args.site_prefix.strip("/") - target_env = args.target_env.strip("/") - segment_pattern = re.compile(r"[a-z0-9][a-z0-9_-]*") - if not segment_pattern.fullmatch(site_prefix): - print("site prefix must be a single lowercase path segment", file=sys.stderr) - return 2 - if not segment_pattern.fullmatch(target_env): - print("target env must be a single lowercase path segment", file=sys.stderr) + try: + deploy_prefix = normalize_prefix(args.deploy_prefix) + except ValueError as exc: + print(str(exc), file=sys.stderr) return 2 dist_dir = Path(args.dist_dir) - deploy_prefix = f"{site_prefix}/{target_env}/" try: bucket = parse_bucket(os.environ["OSS_BUCKET_URI"]) except ValueError as exc: @@ -245,7 +251,7 @@ def main() -> int: print(f"bucket={bucket}") print(f"endpoint={os.environ['OSS_ENDPOINT']}") - print(f"prefix={deploy_prefix}") + print(f"prefix={deploy_prefix or ''}") if args.dry_run: print(f"dry_run=true files={len(files)}") diff --git a/scripts/test_deploy_docs_to_oss.py b/scripts/test_deploy_docs_to_oss.py index d3bc9e1..8495b60 100644 --- a/scripts/test_deploy_docs_to_oss.py +++ b/scripts/test_deploy_docs_to_oss.py @@ -6,6 +6,7 @@ cache_control_for, collect_files, content_type_for, + normalize_prefix, parse_bucket, ) @@ -21,14 +22,25 @@ def test_parse_bucket_rejects_object_prefix(self) -> None: def test_cache_control(self) -> None: self.assertEqual( - cache_control_for("calle-docs-site/prod/assets/index-123.js"), + cache_control_for("assets/index-123.js"), "public, max-age=31536000, immutable", ) self.assertEqual( - cache_control_for("calle-docs-site/prod/index.html"), + cache_control_for("index.html"), "no-cache", ) + def test_normalize_prefix(self) -> None: + self.assertEqual(normalize_prefix(""), "") + self.assertEqual( + normalize_prefix("/calle-docs-site/test/"), + "calle-docs-site/test/", + ) + + def test_normalize_prefix_rejects_unsafe_segments(self) -> None: + with self.assertRaises(ValueError): + normalize_prefix("../docs") + def test_content_type_for_openapi(self) -> None: self.assertEqual( content_type_for(Path("calle.openapi.yaml")),