R2 Pages 6252e016fcb10a8afcce791222bae834bdbc6678 #4479
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |