Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/deploy-oss.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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
16 changes: 10 additions & 6 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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.
44 changes: 25 additions & 19 deletions scripts/deploy_docs_to_oss.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand All @@ -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 '<bucket-root>'}")

if args.dry_run:
print(f"dry_run=true files={len(files)}")
Expand Down
16 changes: 14 additions & 2 deletions scripts/test_deploy_docs_to_oss.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
cache_control_for,
collect_files,
content_type_for,
normalize_prefix,
parse_bucket,
)

Expand All @@ -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")),
Expand Down