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
64 changes: 64 additions & 0 deletions .github/workflows/deploy-website.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Deploy website
on:
push:
branches: [main]
paths: ['website/**', 'blog/**', '.github/workflows/deploy-website.yml']
pull_request:
paths: ['website/**', 'blog/**', '.github/workflows/deploy-website.yml']
permissions:
contents: read
concurrency:
group: website-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: website
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: website/package-lock.json
- run: npm ci
- run: npm run build
- run: npm run check:seo
deploy:
needs: build
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
# Configure protection and secrets on these environments before enabling deployments.
environment: ${{ github.event_name == 'push' && 'website-production' || 'website-preview' }}
defaults:
run:
working-directory: website
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: website/package-lock.json
- run: npm ci
- run: npm run build
- run: npm run check:seo
- name: Deploy production
if: github.event_name == 'push'
run: npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- name: Upload PR preview (trusted branches only)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
Comment thread
tonibergholm marked this conversation as resolved.
Resolved
run: |
if [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then
echo "::warning::Preview not uploaded: configure CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID environment secrets."
exit 0
fi
npx wrangler versions upload --preview-alias pr-${{ github.event.pull_request.number }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
17 changes: 9 additions & 8 deletions sdk/python/agentic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@

from __future__ import annotations

from typing import Any, Iterable, Mapping
from collections.abc import Iterable, Mapping
from typing import Any
Comment thread
tonibergholm marked this conversation as resolved.
Resolved

from .client import (
DEFAULT_SOCKET_PATH,
Expand All @@ -38,22 +39,22 @@
__version__ = "0.1.0"

__all__ = [
"__version__",
"DEFAULT_SOCKET_PATH",
"PROTOCOL_VERSION",
"AgenticClient",
"AgenticConcurrencyError",
"AgenticError",
"AgenticProtocolError",
"AgenticValidationError",
"AgenticInternalError",
"AgenticMemoryError",
"AgenticNotFoundError",
"AgenticProtocolError",
"AgenticStorageError",
"AgenticMemoryError",
"AgenticConcurrencyError",
"AgenticInternalError",
"DEFAULT_SOCKET_PATH",
"AgenticValidationError",
"Commit",
"Diff",
"LogEntry",
"RollbackPlan",
"__version__",
"commit",
"diff",
"log",
Expand Down
16 changes: 8 additions & 8 deletions sdk/python/agentic/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from typing import Any

from ._framing import FrameError, read_frame, write_frame
from .types import Commit, Diff, LogEntry, RollbackPlan
Expand Down Expand Up @@ -167,7 +167,7 @@ class AgenticClient:
this is the right default.
"""

_default: "AgenticClient | None" = None
_default: AgenticClient | None = None
_correlation_counter = itertools.count(1)
_lock = threading.Lock()

Expand All @@ -183,7 +183,7 @@ def __init__(
self.request_timeout = request_timeout

@classmethod
def default(cls) -> "AgenticClient":
def default(cls) -> AgenticClient:
# Double-checked locking on the singleton. The outer fast path
# avoids lock contention once initialised; the inner check
# re-validates after acquiring so concurrent first callers don't
Expand All @@ -210,7 +210,7 @@ def status(self) -> dict[str, Any]:
return {"head": None}
return {"head": reply.get("hash")}

def resolve(self, name: str) -> Optional[str]:
def resolve(self, name: str) -> str | None:
"""Resolve a ref name to a commit hash, or ``None`` if not found.

A daemon-side ``not_found`` class error is collapsed to ``None``;
Expand All @@ -229,11 +229,11 @@ def commit(
message: str,
prompts: dict[str, str | bytes] | None = None,
tools: list[str] | None = None,
model: Optional[str] = None,
model: str | None = None,
no_memory: bool = False,
author: Optional[str] = None,
code_sha: Optional[str] = None,
branch: Optional[str] = None,
author: str | None = None,
code_sha: str | None = None,
branch: str | None = None,
) -> Commit:
# ADR-0010 Decision 3: prompts cross the wire as base64-encoded
# bytes. Accept either ``str`` (encoded as UTF-8 first) or
Expand Down
25 changes: 13 additions & 12 deletions sdk/python/agentic/langgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@
import logging
import os
import tempfile
from collections.abc import AsyncIterator, Iterator, Sequence
from pathlib import Path
from typing import Any, AsyncIterator, Iterator, Optional, Sequence, cast
from typing import Any, cast

from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
Expand Down Expand Up @@ -136,7 +137,7 @@ def put(

# ------------------------------------------------------------ get_tuple

def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
thread_id = _require_thread_id(config)
requested_id = config.get("configurable", {}).get("checkpoint_id")
branch = _branch_for_thread(thread_id)
Expand Down Expand Up @@ -194,11 +195,11 @@ def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:

def list(
self,
config: Optional[RunnableConfig],
config: RunnableConfig | None,
*,
filter: Optional[dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
filter: dict[str, Any] | None = None,
before: RunnableConfig | None = None,
limit: int | None = None,
) -> Iterator[CheckpointTuple]:
"""Iterate historical checkpoints for the thread.

Expand Down Expand Up @@ -228,7 +229,7 @@ def put_writes(
the end of each super-step, so the resume cycle stays correct.
Durable replay of intra-step writes lands with ``ReadBlob``.
"""
return None
return

# ------------------------------------------------- async pass-through

Expand All @@ -241,16 +242,16 @@ async def aput(
) -> RunnableConfig:
return self.put(config, checkpoint, metadata, new_versions)

async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
return self.get_tuple(config)

async def alist(
self,
config: Optional[RunnableConfig],
config: RunnableConfig | None,
*,
filter: Optional[dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
filter: dict[str, Any] | None = None,
before: RunnableConfig | None = None,
limit: int | None = None,
) -> AsyncIterator[CheckpointTuple]:
for tup in self.list(config, filter=filter, before=before, limit=limit):
yield tup
Expand Down
5 changes: 2 additions & 3 deletions sdk/python/agentic/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@
from __future__ import annotations

from datetime import datetime
from typing import Optional

from pydantic import BaseModel


class Commit(BaseModel):
hash: str
branch: str
parent: Optional[str] = None
parent: str | None = None
message: str
author: str
timestamp: datetime
Expand All @@ -37,4 +36,4 @@ class Diff(BaseModel):
class RollbackPlan(BaseModel):
planned_steps: list[str]
executed: bool
new_head_hash: Optional[str] = None
new_head_hash: str | None = None
3 changes: 1 addition & 2 deletions sdk/python/tests/test_langgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from typing import Annotated, Any, TypedDict

import pytest
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer

from agentic import AgenticClient
from agentic.langgraph import (
Expand All @@ -31,8 +32,6 @@
_serialise_envelope,
)

from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer


@pytest.fixture
def short_tmp():
Expand Down
2 changes: 2 additions & 0 deletions website/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ dist/
.env
.env.*
!.env.example

.wrangler/
115 changes: 115 additions & 0 deletions website/DEPLOY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Cloudflare Workers Static Assets

The site is static Astro output; no Worker script or backend is needed.
DNS, domain attachment, redirect rules and the nginx host are manual cutover work.
Do not run the old rsync deployment during cutover.

## One-time setup

1. Choose the Cloudflare account and enable its workers.dev subdomain.
2. Create an API token restricted to that account with **Account / Workers Scripts / Edit**.
No DNS permission is needed for this workflow; attach domains manually with an appropriately privileged dashboard login.
3. Create GitHub environments **website-production** and **website-preview** before enabling deployment.
Restrict website-production to the `main` branch. Require designated owner approval for
website-preview, prevent self-review, and disable administrator bypass where available.
Store `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` as **environment secrets only**;
do not store deployment credentials as repository/organization secrets accessible to PR jobs.
Use a separate Cloudflare preview account and token for website-preview, with no production access.
Initialize `git-agentic-site` and its workers.dev subdomain in both accounts.
A reviewer must inspect the exact PR workflow and package/script changes before approving a preview job:
approved PR code executes with the preview credential. Never approve an untrusted revision.
Do not commit either value. The build job has no environment or deployment credentials.
4. From `website/`, run `npm ci`, `npm run build`, then an authorized initial
`npx wrangler deploy` to create `git-agentic-site` before PR version uploads.
The initial deployment only exposes workers.dev; this config contains no domain routes.
5. Enable Version/Preview URLs for the Worker (also declared with `preview_urls: true`).
Main pushes affecting website files, root-level `blog/**` article sources, or the deployment workflow run `wrangler deploy`.
After environment approval, same-repository PRs run `wrangler versions upload --preview-alias pr-N`;
the URL is printed in the Actions log and does not change the active deployment. Preview URLs use the separate preview account.
When either secret is missing, PR preview upload skips with an Actions warning; production deploy still fails.
Fork PRs build and check without secrets or preview uploads. Never use `pull_request_target` to execute PR code with credentials.

## Routes and local proof

`output: static`, `build.format: file`, and `trailingSlash: never` retain slashless canonical URLs.
The build script generates exact `_redirects` 200 rewrites from every built HTML file.
Wrangler `html_handling: none` prevents automatic canonical redirects; both slash variants serve identical HTML.
Unknown paths use `not_found_handling: 404-page`; there is no custom 404 in this site, so Workers returns its standard 404.
No SPA fallback is used.

The nine page routes are:

- `/`
- `/git-agentic`
- `/src-control`
- `/sentinel`
- `/learn`
- `/learn/why-git-revert-does-not-fix-ai-agent-regressions`
- `/learn/version-control-for-ai-agents`
- `/learn/six-dimensions-of-ai-agent-behavior`
- `/learn/version-control-layer-for-agentic-software`

The build also emits `/sitemap.xml`, `/rss.xml`, and public assets including robots.txt, llms.txt and security.txt.
Canonical tags and sitemap stay on `https://git-agentic.com`.

```sh
cd website
npm ci
npm run build
npm run check:seo
npx wrangler dev --port 8787
# In another terminal:
node scripts/check-routes.mjs
# After cutover, repeat against production:
node scripts/check-routes.mjs https://git-agentic.com
```

The check uses curl to verify every HTML output's canonical, slash and `.html` URL,
compares page bodies against the build, and requires zero redirects and 200 statuses.
It also checks feeds and missing paths (404).
HTML/default responses cache for five minutes with revalidation; hashed `/_astro/*` assets cache immutably for a year.
Styles are currently inlined, so the current build has no hashed Astro assets.

## Custom domains (manual, after preview verification)

Keep a record of current DNS records and nginx configuration before any changes.
The domains must be active zones in the selected Cloudflare account.
In Workers & Pages → git-agentic-site → Settings → Domains & Routes → Add → Custom Domain,
attach `git-agentic.com` and `www.git-agentic.com`.
Cloudflare provisions certificates and DNS for these domains; resolve conflicting records only during the approved cutover.
These are **Custom Domains**, not route patterns pointing back to nginx.
Leave domain bindings out of wrangler.jsonc so CI cannot perform the cutover.

## Alias redirect rules (documented only)

On each `.org` and `.dev` Cloudflare zone, create a Single Redirect rule:

- Match `http.host in {"git-agentic.org" "www.git-agentic.org"}` (use `.dev` for the other zone).
- Dynamic target expression: `concat("https://git-agentic.com", http.request.uri.path)`.
- Status: **301**. Enable **Preserve query string**.

The alias hostnames must have proxied DNS records for rules to execute, with HTTPS certificates active.
Test both HTTP and HTTPS and a nested path with a query string; expect one redirect to the same path/query on `.com`.
Optionally apply the equivalent rule for `www.git-agentic.com` in the `.com` zone after attaching both custom domains,
so canonical traffic goes directly to the apex. Do not match the apex in that rule.

## Cutover and rollback

1. Deploy to workers.dev, inspect the preview and run the route check there.
Confirm headers, feeds, nested pages, `/learn`, `/learn/`, and missing paths.
2. Record the known-good Worker version, current DNS and TTLs, then attach the two custom domains during the approved window.
Keep the Hetzner nginx vhost, files and certificates intact until production is verified.
3. Check public HTTPS/certificates on apex and www, repeat the route check on apex,
and verify security/cache headers and representative assets. Then enable/test the alias redirect rules.
4. Monitor traffic/errors through at least the previous DNS TTL before retiring anything on the old host.
5. For a bad content release, select the previous known-good Worker deployment in the dashboard
(or revert the commit and let CI deploy). For a hosting cutover failure, remove the new Custom Domain bindings,
restore the recorded apex/www DNS records to the old host, and disable any newly introduced redirect rules as needed.
Allow DNS caches to expire, then verify the old site's TLS and pages. The old `/learn` failure will remain until separately fixed there.

## References

- [HTML handling](https://developers.cloudflare.com/workers/static-assets/routing/advanced/html-handling/)
- [Static redirects and 200 rewrites](https://developers.cloudflare.com/workers/static-assets/redirects/)
- [Custom domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/)
- [Version URLs](https://developers.cloudflare.com/workers/versions-and-deployments/version-urls/)
Loading
Loading