Using openclaw to set up CI/CD for an Astro project — a real-world sensakai.com case study


Using openclaw to set up CI/CD for an Astro project — a real-world sensakai.com case study

This post summarizes the practical lessons learned while building CI/CD for sensakai (Astro). The twist: I used openclaw as a DevOps copilot to:

  • review project structure and build scripts,
  • propose a standard pipeline layout,
  • provide a secrets/variables checklist,
  • help debug common CI failures,
  • turn the implementation into a reusable playbook.

Security note: sensitive details (IPs/domains/users/keys/internal paths) are replaced with placeholders like <...>. This post focuses on patterns and repeatable steps.


1) Pipeline goals

The pipeline is structured into three stages:

  1. test: install dependencies and run lint + astro check to fail fast.
  2. build: build dist/ and store it as artifacts.
  3. deploy: only on main, fetch dist/ artifacts and deploy via SSH + rsync.

Upgrade included:

  • a post-deploy smoke test (HTTP check) to verify the site is actually serving correctly.

2) Repo prerequisites

2.1 Minimal scripts in package.json

You should have clearly defined scripts:

  • lint: eslint .
  • check: astro check
  • build: astro build

Why this matters:

  • lint catches style/syntax issues.
  • astro check catches type/integration issues early.
  • build produces dist/ for deployment.

How openclaw helped: it enforced this “contract” before writing any CI YAML.

2.2 Deployment output

Astro’s default output is:

  • dist/

3) Runner design: Shell vs Docker executor

This pipeline is designed for a Shell executor (jobs run directly on the runner host), because:

  • the runner host can access your internal/VPN network (e.g., Tailscale) like a real machine,
  • SSH/rsync deployment is straightforward.

How openclaw helped: it prompted the right executor choice given networking constraints.


4) Step-by-step CI/CD design

4.1 Key principles

  • Fail fast: run lint/check first.
  • Reproducible: use npm ci with a lockfile.
  • Traceable: deploy the exact dist/ produced by the build job (artifacts).
  • Controlled release: deploy only from main.

4.2 npm cache

Recommended:

  • cache .npm/
  • cache key based on package-lock.json

Install:

npm ci --cache .npm --prefer-offline

4.3 dist/ artifacts

Artifacts make deploy deterministic: deploy uses the build output, not a rebuilt version.


5) SSH/rsync deployment (common pitfalls)

5.1 Variables (GitLab CI/CD Variables)

Set these variables (example names):

  • DEPLOY_SSH_KEY_B64
  • DEPLOY_SSH_HOST
  • DEPLOY_SSH_PORT
  • DEPLOY_SSH_USER
  • DEPLOY_TARGET_DIR

Security recommendations:

  • never commit keys
  • least-privileged deploy user
  • consider masked/protected variables

5.2 rsync --delete caution

Pros: keeps the server free of stale files. Risk: if the target directory is wrong, you can delete the wrong content.

Mitigations:

  • deploy into a dedicated subdirectory
  • optionally enforce a marker-file check

6) Upgrade: post-deploy smoke test (HTTP check)

After rsync finishes, validate an HTTP endpoint to ensure:

  • the web server is serving the new content
  • routing/DNS is correct
  • no 50x/connection failures

Approach:

  • set SMOKE_TEST_URL (e.g., https://<your-domain>/)
  • (recommended) set SMOKE_TEST_EXPECT to assert a marker string in the final HTML (e.g., sensakai)
  • the smoke test will:
    • accept 200/301/302 on the initial request
    • follow redirects to fetch final HTML and check the marker string
    • retry to allow the service to warm up

7) Real configuration: .gitlab-ci.yml

Below is the actual .gitlab-ci.yml used for sensakai (no secrets included; sensitive values live in GitLab Variables):

stages:
  - test
  - build
  - deploy

# NOTE: This pipeline is intended for a SHELL executor (runs on the runner host).
# It can reach your Tailscale network directly, unlike Docker executor containers.

default:
  tags:
    - sensakai-shell
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - .npm/

test:
  stage: test
  script:
    - node -v
    - npm -v
    - npm ci --cache .npm --prefer-offline
    - npm run lint
    - npm run check

build:
  stage: build
  script:
    - node -v
    - npm -v
    - npm ci --cache .npm --prefer-offline
    - npm run build
  artifacts:
    name: "dist-$CI_COMMIT_SHA"
    paths:
      - dist/
    expire_in: 7 days

deploy_prod:
  stage: deploy
  dependencies:
    - build
  script:
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    # DEPLOY_SSH_KEY_B64 should be a normal GitLab variable (base64 of the private key file)
    - echo "$DEPLOY_SSH_KEY_B64" | base64 -d > ~/.ssh/id_ed25519
    - chmod 600 ~/.ssh/id_ed25519
    # Sanity-check the key format (prints fingerprint)
    - ssh-keygen -lf ~/.ssh/id_ed25519
    # Best-effort host key fetch (don't fail the job if keyscan can't run)
    - ssh-keyscan -p "$DEPLOY_SSH_PORT" "$DEPLOY_SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
    # Deploy using ONLY this identity
    - rsync -az --delete -e "ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes -p $DEPLOY_SSH_PORT" dist/ "$DEPLOY_SSH_USER@$DEPLOY_SSH_HOST:$DEPLOY_TARGET_DIR/"

    # Smoke test (HTTP check) after deploy
    # Set SMOKE_TEST_URL in GitLab CI/CD Variables, e.g. https://<your-domain>/
    # Optional: set SMOKE_TEST_EXPECT (default: sensakai) to assert a marker string exists in the final HTML.
    - |
      if [ -n "${SMOKE_TEST_URL:-}" ]; then
        EXPECT="${SMOKE_TEST_EXPECT:-sensakai}"
        echo "Running smoke test: $SMOKE_TEST_URL (expect marker: $EXPECT)"

        for i in $(seq 1 10); do
          # 1) Check initial HTTP status without following redirects (accept 200/301/302)
          CODE=$(curl -sS -o /dev/null --max-time 10 -w "%{http_code}" "$SMOKE_TEST_URL" || true)
          if [ "$CODE" = "200" ] || [ "$CODE" = "301" ] || [ "$CODE" = "302" ]; then
            # 2) Fetch final HTML (follow redirects) and assert marker string exists
            BODY=$(curl -fsSL --max-time 10 "$SMOKE_TEST_URL" || true)
            if echo "$BODY" | grep -qi -- "$EXPECT"; then
              echo "Smoke test OK (status=$CODE, marker found)"
              exit 0
            fi
            echo "Smoke test status OK (status=$CODE) but marker not found (attempt $i/10). Retrying in 3s..."
          else
            echo "Smoke test failed (status=$CODE) (attempt $i/10). Retrying in 3s..."
          fi
          sleep 3
        done

        echo "Smoke test FAILED after 10 attempts"
        exit 1
      else
        echo "SMOKE_TEST_URL is not set; skipping smoke test"
      fi
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: on_success

8) Implementation checklist

  1. Repo includes lint, check, and build and builds into dist/.
  2. Runner (Shell executor) has node/npm + ssh + rsync + curl.
  3. Server has a deploy user and a writable target directory; public key is added to authorized_keys.
  4. GitLab Variables are set: DEPLOY_*, and (recommended) SMOKE_TEST_URL + SMOKE_TEST_EXPECT=sensakai.
  5. Push to main to trigger deploy + smoke test.

9) Conclusion: openclaw as a DevOps copilot

CI/CD is rarely hard because of YAML; it’s hard because of the small moving pieces: scripts, runner environment, secrets, SSH permissions, deployment safety, and post-deploy validation. openclaw helped keep the process checklist-driven, security-aware, and easy to document.


Note

This post was automatically written by openclaw (based on the project’s real pipeline configuration) and edited to remove sensitive information.