Skip to content

R2 Pages 6252e016fcb10a8afcce791222bae834bdbc6678 #4479

R2 Pages 6252e016fcb10a8afcce791222bae834bdbc6678

R2 Pages 6252e016fcb10a8afcce791222bae834bdbc6678 #4479

Workflow file for this run

name: R2 Pages
run-name: R2 Pages ${{ github.event_name == 'workflow_dispatch' && inputs.request_id || github.sha }}
on:
push:
branches:
- main
paths:
- "docs/**"
- "scripts/docs-site/**"
- "workers/**"
- "wrangler.toml"
- "package.json"
- "package-lock.json"
- ".github/workflows/r2-pages.yml"
- ".openclaw-sync/source.json"
workflow_dispatch:
inputs:
artifact_scope:
description: "Artifact scope to build and upload."
required: false
type: choice
default: auto
options:
- auto
- full
- shell
- locale
- page
locale:
description: "Locale code for locale/page scoped uploads."
required: false
type: string
default: ""
page_path:
description: "Locale-relative page route for page scoped uploads, for example channels/line."
required: false
type: string
default: ""
force_upload:
description: "Audit R2 objects before upload; unchanged objects remain cache hits."
required: false
type: boolean
default: false
delete_bucket_orphans:
description: "List the bucket and delete objects missing from the manifest."
required: false
type: boolean
default: false
request_id:
description: "Unique caller id used by dispatch waiters to resolve this run."
required: false
type: string
default: ""
smoke_commit_range:
description: "Optional <base>..<head> forwarded to docs-live-smoke for added-route assertions."
type: string
required: false
default: ""
permissions:
actions: write
contents: read
concurrency:
group: r2-pages
queue: max
cancel-in-progress: false
jobs:
deploy:
name: Build and upload R2 site
runs-on: ubuntu-latest
timeout-minutes: 60
env:
DOCS_SITE_CANONICAL_ORIGIN: https://docs.openclaw.ai
DOCS_SITE_CNAME: docs.openclaw.ai
environment:
name: cloudflare
url: https://docs.openclaw.ai
steps:
- name: Check out
uses: actions/checkout@v7.0.1
- name: Classify artifact scope
id: artifact-scope
env:
AFTER_SHA: ${{ github.sha }}
BEFORE_SHA: ${{ github.event.before || '' }}
EVENT_NAME: ${{ github.event_name }}
REQUESTED_SCOPE: ${{ inputs.artifact_scope || 'auto' }}
REQUESTED_LOCALE: ${{ inputs.locale || '' }}
REQUESTED_PAGE_PATH: ${{ inputs.page_path || '' }}
run: |
set -euo pipefail
scope="${REQUESTED_SCOPE}"
if [ -z "${scope}" ]; then scope="auto"; fi
case "${scope}" in
auto|full|shell|locale|page) ;;
*) echo "Invalid artifact scope: ${scope}" >&2; exit 1 ;;
esac
if [ "${scope}" = "locale" ] || [ "${scope}" = "page" ]; then
if [ -z "${REQUESTED_LOCALE}" ]; then
echo "locale is required for ${scope} artifact scope" >&2
exit 1
fi
fi
if [ "${scope}" = "page" ] && [ -z "${REQUESTED_PAGE_PATH}" ]; then
echo "page_path is required for page artifact scope" >&2
exit 1
fi
if [ "${scope}" = "auto" ]; then
scope="full"
if [ "${EVENT_NAME}" = "push" ] && [ -n "${BEFORE_SHA}" ] && ! printf '%s' "${BEFORE_SHA}" | grep -Eq '^0+$'; then
git fetch --quiet --no-tags --depth=1 origin "${BEFORE_SHA}" || true
changed="$(git diff --name-only "${BEFORE_SHA}" "${AFTER_SHA}" || git diff-tree --no-commit-id --name-only -r "${AFTER_SHA}")"
printf '%s\n' "${changed}" | sed '/^$/d' > /tmp/openclaw-docs-changed.txt
if ! grep -Ev '^(\.github/workflows/(pages|r2-pages)\.yml|workers/.*|wrangler\.toml|README\.md|CLOUDFLARE\.md|scripts/docs-site/r2-upload\.mjs)$' /tmp/openclaw-docs-changed.txt >/tmp/openclaw-docs-artifact.txt; then
scope="none"
elif ! grep -Eq '^(docs/|\.openclaw-sync/source\.json|scripts/docs-site/search-index\.mjs$)' /tmp/openclaw-docs-changed.txt \
&& grep -Eq '^(Makefile|package\.json|package-lock\.json|scripts/docs-site/|\.github/workflows/r2-pages\.yml|README\.md|CLOUDFLARE\.md)$|^(scripts/docs-site/)' /tmp/openclaw-docs-changed.txt; then
if ! grep -Ev '^(Makefile|package\.json|package-lock\.json|scripts/docs-site/|\.github/workflows/r2-pages\.yml|README\.md|CLOUDFLARE\.md)' /tmp/openclaw-docs-changed.txt >/tmp/openclaw-docs-nonshell.txt; then
scope="shell"
fi
fi
fi
fi
deploy_worker=0
if [ -s /tmp/openclaw-docs-changed.txt ] && grep -Eq '^(workers/|wrangler\.toml$)' /tmp/openclaw-docs-changed.txt; then
deploy_worker=1
fi
{
echo "scope=${scope}"
case "${scope}" in
shell|locale|page) echo "upload_scope=${scope}" ;;
*) echo "upload_scope=all" ;;
esac
echo "partial=$([[ "${scope}" = "shell" || "${scope}" = "locale" || "${scope}" = "page" ]] && echo 1 || echo 0)"
echo "deploy_worker=${deploy_worker}"
} >> "${GITHUB_OUTPUT}"
echo "Artifact scope: ${scope}"
- name: Refresh scoped docs content from main
if: github.event_name == 'workflow_dispatch' && (steps.artifact-scope.outputs.scope == 'locale' || steps.artifact-scope.outputs.scope == 'page')
id: scoped-content
run: |
set -euo pipefail
git fetch --quiet origin main:refs/remotes/origin/main
content_sha="$(git rev-parse refs/remotes/origin/main)"
# Scoped translation deploys must test the workflow/ref under review
# while publishing the content that the finalizer just wrote to main.
git checkout "${content_sha}" -- docs .openclaw-sync/source.json
echo "content_sha=${content_sha}" >> "${GITHUB_OUTPUT}"
echo "Scoped docs content refreshed from main ${content_sha}."
# Admit once while holding the deployment queue, before expensive work.
# Catch-up after publication schedules missing successors without a veto.
- name: Check current docs main
if: steps.artifact-scope.outputs.scope != 'none' || steps.artifact-scope.outputs.deploy_worker == '1'
id: current-main
env:
SCOPED_CONTENT_SHA: ${{ steps.scoped-content.outputs.content_sha || '' }}
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before || '' }}
SMOKE_COMMIT_RANGE: ${{ inputs.smoke_commit_range || '' }}
run: node scripts/docs-site/head-drift.mjs admit
- name: Fail stale scoped translation deploy
if: github.event_name == 'workflow_dispatch' && steps.current-main.outputs.stale == 'true' && (steps.artifact-scope.outputs.scope == 'locale' || steps.artifact-scope.outputs.scope == 'page')
run: |
echo "Scoped translation content went stale before build admission; retry this dispatch against latest main." >&2
exit 1
- name: Read source metadata
if: steps.current-main.outputs.stale == 'false' && (steps.artifact-scope.outputs.scope == 'full' || steps.artifact-scope.outputs.scope == 'locale' || steps.artifact-scope.outputs.scope == 'page')
id: source-meta
run: |
node - <<'NODE'
const fs = require("node:fs");
const data = JSON.parse(fs.readFileSync(".openclaw-sync/source.json", "utf8"));
if (!data.repository || !data.sha) throw new Error("invalid .openclaw-sync/source.json");
fs.appendFileSync(process.env.GITHUB_OUTPUT, `repository=${data.repository}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `sha=${data.sha}\n`);
NODE
- name: Check out OpenClaw source
if: steps.current-main.outputs.stale == 'false' && (steps.artifact-scope.outputs.scope == 'full' || steps.artifact-scope.outputs.scope == 'locale' || steps.artifact-scope.outputs.scope == 'page')
uses: actions/checkout@v7.0.1
with:
repository: ${{ steps.source-meta.outputs.repository }}
ref: ${{ steps.source-meta.outputs.sha }}
path: source
fetch-depth: 1
- name: Set up Node
if: steps.current-main.outputs.stale == 'false' && (steps.artifact-scope.outputs.scope != 'none' || steps.artifact-scope.outputs.deploy_worker == '1')
uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- name: Install
if: steps.current-main.outputs.stale == 'false' && (steps.artifact-scope.outputs.scope != 'none' || steps.artifact-scope.outputs.deploy_worker == '1')
run: npm ci
- name: Install librsvg2-bin
if: steps.current-main.outputs.stale == 'false' && steps.artifact-scope.outputs.scope != 'none'
run: sudo apt-get update && sudo apt-get install -y librsvg2-bin
- name: Build R2 artifact
if: steps.current-main.outputs.stale == 'false' && steps.artifact-scope.outputs.scope != 'none'
env:
DOCS_SOURCE_REPO_DIR: source
DOCS_SOURCE_REPO_URL: https://github.com/${{ steps.source-meta.outputs.repository }}
DOCS_SOURCE_SHA: ${{ steps.source-meta.outputs.sha }}
run: |
if [ "${{ steps.artifact-scope.outputs.scope }}" = "shell" ]; then
npm run docs:build:r2:shell
else
npm run docs:build:r2
fi
- name: Smoke generated site
if: steps.current-main.outputs.stale == 'false' && steps.artifact-scope.outputs.scope != 'none'
run: |
if [ "${{ steps.artifact-scope.outputs.scope }}" = "shell" ]; then
npm run docs:smoke:shell
else
npm run docs:smoke
fi
- name: Resolve R2 credentials
if: steps.artifact-scope.outputs.scope != 'none' && steps.current-main.outputs.stale == 'false'
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
STORED_R2_ACCESS_KEY_ID: ${{ secrets.OPENCLAW_R2_ACCESS_KEY_ID }}
STORED_R2_SECRET_ACCESS_KEY: ${{ secrets.OPENCLAW_R2_SECRET_ACCESS_KEY }}
STORED_R2_SESSION_TOKEN: ${{ secrets.OPENCLAW_R2_SESSION_TOKEN }}
run: |
set -euo pipefail
verify_response=""
if [ -n "${CLOUDFLARE_API_TOKEN:-}" ] && [ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ]; then
verify_response="$(curl -fsS -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/tokens/verify" || true)"
fi
if [ -z "${verify_response}" ] && [ -n "${CLOUDFLARE_API_TOKEN:-}" ]; then
verify_response="$(curl -fsS -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" "https://api.cloudflare.com/client/v4/user/tokens/verify" || true)"
fi
if [ -n "${verify_response}" ]; then
VERIFY_RESPONSE="${verify_response}" node - <<'NODE'
const crypto = require("node:crypto");
const fs = require("node:fs");
const token = process.env.CLOUDFLARE_API_TOKEN;
const response = JSON.parse(process.env.VERIFY_RESPONSE || "{}");
const accessKeyId = response?.result?.id;
if (!response?.success || !accessKeyId) {
throw new Error("Cloudflare token verify response did not include a token id");
}
async function main() {
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
const bucket = "openclaw-docs";
if (accountId) {
try {
const temporary = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/r2/temp-access-credentials`, {
body: JSON.stringify({
bucket,
parentAccessKeyId: accessKeyId,
permission: "object-read-write",
ttlSeconds: 3600,
}),
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
method: "POST",
});
if (temporary.ok) {
const data = await temporary.json();
const result = data?.result;
if (data?.success && result?.accessKeyId && result?.secretAccessKey && result?.sessionToken) {
console.log(`::add-mask::${result.accessKeyId}`);
console.log(`::add-mask::${result.secretAccessKey}`);
console.log(`::add-mask::${result.sessionToken}`);
fs.appendFileSync(process.env.GITHUB_ENV, `OPENCLAW_R2_ACCESS_KEY_ID=${result.accessKeyId}\n`);
fs.appendFileSync(process.env.GITHUB_ENV, `OPENCLAW_R2_SECRET_ACCESS_KEY=${result.secretAccessKey}\n`);
fs.appendFileSync(process.env.GITHUB_ENV, `OPENCLAW_R2_SESSION_TOKEN=${result.sessionToken}\n`);
return;
}
}
console.log(`::notice::Temporary R2 credential mint failed with status ${temporary.status}; falling back to direct token-derived credentials.`);
} catch (error) {
console.log(`::notice::Temporary R2 credential mint failed; falling back to direct token-derived credentials.`);
}
}
const secretAccessKey = crypto.createHash("sha256").update(token).digest("hex");
console.log(`::add-mask::${accessKeyId}`);
console.log(`::add-mask::${secretAccessKey}`);
fs.appendFileSync(process.env.GITHUB_ENV, `OPENCLAW_R2_ACCESS_KEY_ID=${accessKeyId}\n`);
fs.appendFileSync(process.env.GITHUB_ENV, `OPENCLAW_R2_SECRET_ACCESS_KEY=${secretAccessKey}\n`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
exit 0
fi
if [ -n "${CLOUDFLARE_API_TOKEN:-}" ]; then
echo "::warning::CLOUDFLARE_API_TOKEN could not be verified; falling back to OPENCLAW_R2_* secrets if present."
fi
if [ -n "${STORED_R2_ACCESS_KEY_ID:-}" ] && [ -n "${STORED_R2_SECRET_ACCESS_KEY:-}" ]; then
printf '::add-mask::%s\n' "${STORED_R2_ACCESS_KEY_ID}"
printf '::add-mask::%s\n' "${STORED_R2_SECRET_ACCESS_KEY}"
if [ -n "${STORED_R2_SESSION_TOKEN:-}" ]; then
printf '::add-mask::%s\n' "${STORED_R2_SESSION_TOKEN}"
fi
{
printf 'OPENCLAW_R2_ACCESS_KEY_ID=%s\n' "${STORED_R2_ACCESS_KEY_ID}"
printf 'OPENCLAW_R2_SECRET_ACCESS_KEY=%s\n' "${STORED_R2_SECRET_ACCESS_KEY}"
if [ -n "${STORED_R2_SESSION_TOKEN:-}" ]; then
printf 'OPENCLAW_R2_SESSION_TOKEN=%s\n' "${STORED_R2_SESSION_TOKEN}"
fi
} >> "${GITHUB_ENV}"
exit 0
fi
echo "CLOUDFLARE_API_TOKEN or OPENCLAW_R2_* upload credentials are required" >&2
exit 1
- name: Upload changed R2 objects
id: upload-r2
if: steps.artifact-scope.outputs.scope != 'none' && steps.current-main.outputs.stale == 'false'
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_R2_BUCKET: openclaw-docs
R2_DELETE_BUCKET_ORPHANS: ${{ github.event_name == 'workflow_dispatch' && inputs.delete_bucket_orphans == true && '1' || '0' }}
R2_REFRESH_CONCURRENCY: 32
R2_UPLOAD_CONCURRENCY: 64
R2_UPLOAD_PARTIAL: ${{ steps.artifact-scope.outputs.partial }}
R2_UPLOAD_FORCE: ${{ github.event_name == 'workflow_dispatch' && inputs.force_upload == true && '1' || '0' }}
R2_UPLOAD_SCOPE: ${{ steps.artifact-scope.outputs.upload_scope }}
R2_UPLOAD_LOCALE: ${{ inputs.locale || '' }}
R2_UPLOAD_PAGE_PATH: ${{ inputs.page_path || '' }}
run: npm run docs:r2:upload
- name: Deploy Worker before live smoke
id: deploy-worker
if: steps.artifact-scope.outputs.deploy_worker == '1' && steps.current-main.outputs.stale == 'false'
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npx wrangler@4.119.0 deploy --config wrangler.toml \
--tag "${GITHUB_SHA::12}" \
--message "openclaw/docs ${GITHUB_SHA}"
- name: Dispatch live smoke
if: steps.current-main.outputs.stale == 'false' && (steps.artifact-scope.outputs.deploy_worker == '1' || (steps.artifact-scope.outputs.scope != 'none' && (steps.upload-r2.outputs.changed != '0' || steps.upload-r2.outputs.deleted != '0')))
env:
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before || '' }}
AFTER_SHA: ${{ github.sha }}
SMOKE_COMMIT_RANGE: ${{ inputs.smoke_commit_range || '' }}
run: |
set -euo pipefail
range=""
if [[ "${EVENT_NAME}" == "push" && "${BEFORE_SHA}" =~ ^[0-9a-f]{7,40}$ && ! "${BEFORE_SHA}" =~ ^0+$ ]]; then
range="${BEFORE_SHA}..${AFTER_SHA}"
elif [ "${EVENT_NAME}" = "workflow_dispatch" ]; then
range="${SMOKE_COMMIT_RANGE}"
fi
gh workflow run docs-live-smoke.yml --ref main -f commit_range="${range}"
- name: Catch up docs main after publication
if: ${{ !cancelled() && steps.current-main.outputs.stale == 'false' && (steps.upload-r2.outcome == 'success' || steps.deploy-worker.outcome == 'success') && (steps.artifact-scope.outputs.deploy_worker != '1' || steps.deploy-worker.outcome == 'success') }}
env:
SCOPED_CONTENT_SHA: ${{ steps.scoped-content.outputs.content_sha || '' }}
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before || '' }}
SMOKE_COMMIT_RANGE: ${{ inputs.smoke_commit_range || '' }}
run: node scripts/docs-site/head-drift.mjs catch-up