openclaw/openclaw

13 workflows · maturity 83% · 10 patterns · GitHub ↗

Security 37.12/100

Practices

✓ Matrix✓ Permissions✓ Security scan○ AI review✓ Cache✓ Concurrency○ Reusable workflows

Detected patterns

Security dimensions

permissions
9.6
security scan
12.5
supply chain
0
secret handling
15
harden runner
0

Tools: github/codeql-action/analyze, github/codeql-action/autobuild, github/codeql-action/init

Workflows (13)

auto-response perms .github/workflows/auto-response.yml
Triggers
issues, issue_comment, pull_request_target
Runs on
blacksmith-16vcpu-ubuntu-2404
Jobs
auto-response
Actions
actions/create-github-app-token, actions/create-github-app-token
View raw YAML
name: Auto response

on:
  issues:
    types: [opened, edited, labeled]
  issue_comment:
    types: [created]
  pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned label automation; no untrusted checkout or code execution
    types: [labeled]

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }}
  cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}

permissions: {}

jobs:
  auto-response:
    permissions:
      issues: write
      pull-requests: write
    runs-on: blacksmith-16vcpu-ubuntu-2404
    steps:
      - uses: actions/create-github-app-token@v2
        id: app-token
        continue-on-error: true
        with:
          app-id: "2729701"
          private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
      - uses: actions/create-github-app-token@v2
        id: app-token-fallback
        if: steps.app-token.outcome == 'failure'
        with:
          app-id: "2971289"
          private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
      - name: Handle labeled items
        uses: actions/github-script@v8
        with:
          github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
          script: |
            // Labels prefixed with "r:" are auto-response triggers.
            const activePrLimit = 10;
            const rules = [
              {
                label: "r: skill",
                close: true,
                message:
                  "Thanks for the contribution! New skills should be published to [Clawhub](https://clawhub.ai) for everyone to use. We’re keeping the core lean on skills, so I’m closing this out.",
              },
              {
                label: "r: support",
                close: true,
                message:
                  "Please use [our support server](https://discord.gg/clawd) and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.openclaw.ai/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.",
              },
              {
                label: "r: no-ci-pr",
                close: true,
                message:
                  "Please don't make PRs for test failures on main.\n\n" +
                  "The team is aware of those and will handle them directly on the codebase, not only fixing the tests but also investigating what the root cause is. Having to sift through test-fix-PRs (including some that have been out of date for weeks...) on top of that doesn't help. There are already way too many PRs for humans to manage; please don't make the flood worse.\n\n" +
                  "Thank you.",
              },
              {
                label: "r: too-many-prs",
                close: true,
                message:
                  `Closing this PR because the author has more than ${activePrLimit} active PRs in this repo. ` +
                  "Please reduce the active PR queue and reopen or resubmit once it is back under the limit. You can close your own PRs to get back under the limit.",
              },
              {
                label: "r: testflight",
                close: true,
                commentTriggers: ["testflight"],
                message: "Not available, build from source.",
              },
              {
                label: "r: third-party-extension",
                close: true,
                message:
                  "Please make this as a third-party plugin that you maintain yourself in your own repo. Docs: https://docs.openclaw.ai/plugin. Feel free to open a PR after to add it to our community plugins page: https://docs.openclaw.ai/plugins/community",
              },
              {
                label: "r: moltbook",
                close: true,
                lock: true,
                lockReason: "off-topic",
                commentTriggers: ["moltbook"],
                message:
                  "OpenClaw is not affiliated with Moltbook, and issues related to Moltbook should not be submitted here.",
              },
            ];

            const maintainerTeam = "maintainer";
            const pingWarningMessage =
              "Please don’t spam-ping multiple maintainers at once. Be patient, or join our community Discord for help: https://discord.gg/clawd";
            const mentionRegex = /@([A-Za-z0-9-]+)/g;
            const maintainerCache = new Map();
            const normalizeLogin = (login) => login.toLowerCase();
            const bugSubtypeLabelSpecs = {
              regression: {
                color: "D93F0B",
                description: "Behavior that previously worked and now fails",
              },
              "bug:crash": {
                color: "B60205",
                description: "Process/app exits unexpectedly or hangs",
              },
              "bug:behavior": {
                color: "D73A4A",
                description: "Incorrect behavior without a crash",
              },
            };
            const bugTypeToLabel = {
              "Regression (worked before, now fails)": "regression",
              "Crash (process/app exits or hangs)": "bug:crash",
              "Behavior bug (incorrect output/state without crash)": "bug:behavior",
            };
            const bugSubtypeLabels = Object.keys(bugSubtypeLabelSpecs);

            const extractIssueFormValue = (body, field) => {
              if (!body) {
                return "";
              }
              const escapedField = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
              const regex = new RegExp(
                `(?:^|\\n)###\\s+${escapedField}\\s*\\n([\\s\\S]*?)(?=\\n###\\s+|$)`,
                "i",
              );
              const match = body.match(regex);
              if (!match) {
                return "";
              }
              for (const line of match[1].split("\n")) {
                const trimmed = line.trim();
                if (trimmed) {
                  return trimmed;
                }
              }
              return "";
            };

            const ensureLabelExists = async (name, color, description) => {
              try {
                await github.rest.issues.getLabel({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  name,
                });
              } catch (error) {
                if (error?.status !== 404) {
                  throw error;
                }
                await github.rest.issues.createLabel({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  name,
                  color,
                  description,
                });
              }
            };

            const syncBugSubtypeLabel = async (issue, labelSet) => {
              if (!labelSet.has("bug")) {
                return;
              }

              const selectedBugType = extractIssueFormValue(issue.body ?? "", "Bug type");
              const targetLabel = bugTypeToLabel[selectedBugType];
              if (!targetLabel) {
                return;
              }

              const targetSpec = bugSubtypeLabelSpecs[targetLabel];
              await ensureLabelExists(targetLabel, targetSpec.color, targetSpec.description);

              for (const subtypeLabel of bugSubtypeLabels) {
                if (subtypeLabel === targetLabel) {
                  continue;
                }
                if (!labelSet.has(subtypeLabel)) {
                  continue;
                }
                try {
                  await github.rest.issues.removeLabel({
                    owner: context.repo.owner,
                    repo: context.repo.repo,
                    issue_number: issue.number,
                    name: subtypeLabel,
                  });
                  labelSet.delete(subtypeLabel);
                } catch (error) {
                  if (error?.status !== 404) {
                    throw error;
                  }
                }
              }

              if (!labelSet.has(targetLabel)) {
                await github.rest.issues.addLabels({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: issue.number,
                  labels: [targetLabel],
                });
                labelSet.add(targetLabel);
              }
            };

            const isMaintainer = async (login) => {
              if (!login) {
                return false;
              }
              const normalized = normalizeLogin(login);
              if (maintainerCache.has(normalized)) {
                return maintainerCache.get(normalized);
              }
              let isMember = false;
              try {
                const membership = await github.rest.teams.getMembershipForUserInOrg({
                  org: context.repo.owner,
                  team_slug: maintainerTeam,
                  username: normalized,
                });
                isMember = membership?.data?.state === "active";
              } catch (error) {
                if (error?.status !== 404) {
                  throw error;
                }
              }
              maintainerCache.set(normalized, isMember);
              return isMember;
            };

            const countMaintainerMentions = async (body, authorLogin) => {
              if (!body) {
                return 0;
              }
              const normalizedAuthor = authorLogin ? normalizeLogin(authorLogin) : "";
              if (normalizedAuthor && (await isMaintainer(normalizedAuthor))) {
                return 0;
              }

              const haystack = body.toLowerCase();
              const teamMention = `@${context.repo.owner.toLowerCase()}/${maintainerTeam}`;
              if (haystack.includes(teamMention)) {
                return 3;
              }

              const mentions = new Set();
              for (const match of body.matchAll(mentionRegex)) {
                mentions.add(normalizeLogin(match[1]));
              }
              if (normalizedAuthor) {
                mentions.delete(normalizedAuthor);
              }

              let count = 0;
              for (const login of mentions) {
                if (await isMaintainer(login)) {
                  count += 1;
                }
              }
              return count;
            };

            const triggerLabel = "trigger-response";
            const activePrLimitLabel = "r: too-many-prs";
            const activePrLimitOverrideLabel = "r: too-many-prs-override";
            const target = context.payload.issue ?? context.payload.pull_request;
            if (!target) {
              return;
            }

            const labelSet = new Set(
              (target.labels ?? [])
                .map((label) => (typeof label === "string" ? label : label?.name))
                .filter((name) => typeof name === "string"),
            );

            const issue = context.payload.issue;
            const pullRequest = context.payload.pull_request;
            const comment = context.payload.comment;
            if (comment) {
              const authorLogin = comment.user?.login ?? "";
              if (comment.user?.type === "Bot" || authorLogin.endsWith("[bot]")) {
                return;
              }

              const commentBody = comment.body ?? "";
              const responses = [];
              const mentionCount = await countMaintainerMentions(commentBody, authorLogin);
              if (mentionCount >= 3) {
                responses.push(pingWarningMessage);
              }

              const commentHaystack = commentBody.toLowerCase();
              const commentRule = rules.find((item) =>
                (item.commentTriggers ?? []).some((trigger) =>
                  commentHaystack.includes(trigger),
                ),
              );
              if (commentRule) {
                responses.push(commentRule.message);
              }

              if (responses.length > 0) {
                await github.rest.issues.createComment({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: target.number,
                  body: responses.join("\n\n"),
                });
              }
              return;
            }

            if (issue) {
              const action = context.payload.action;
              if (action === "opened" || action === "edited") {
                const issueText = `${issue.title ?? ""}\n${issue.body ?? ""}`.trim();
                const authorLogin = issue.user?.login ?? "";
                const mentionCount = await countMaintainerMentions(
                  issueText,
                  authorLogin,
                );
                if (mentionCount >= 3) {
                  await github.rest.issues.createComment({
                    owner: context.repo.owner,
                    repo: context.repo.repo,
                    issue_number: issue.number,
                    body: pingWarningMessage,
                  });
                }

                await syncBugSubtypeLabel(issue, labelSet);
              }
            }

            const hasTriggerLabel = labelSet.has(triggerLabel);
            if (hasTriggerLabel) {
              labelSet.delete(triggerLabel);
              try {
                await github.rest.issues.removeLabel({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: target.number,
                  name: triggerLabel,
                });
              } catch (error) {
                if (error?.status !== 404) {
                  throw error;
                }
              }
            }

            const isLabelEvent = context.payload.action === "labeled";
            if (!hasTriggerLabel && !isLabelEvent) {
              return;
            }

            if (issue) {
              const title = issue.title ?? "";
              const body = issue.body ?? "";
              const haystack = `${title}\n${body}`.toLowerCase();
              const hasMoltbookLabel = labelSet.has("r: moltbook");
              const hasTestflightLabel = labelSet.has("r: testflight");
              const hasSecurityLabel = labelSet.has("security");
              if (title.toLowerCase().includes("security") && !hasSecurityLabel) {
                await github.rest.issues.addLabels({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: issue.number,
                  labels: ["security"],
                });
                labelSet.add("security");
              }
              if (title.toLowerCase().includes("testflight") && !hasTestflightLabel) {
                await github.rest.issues.addLabels({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: issue.number,
                  labels: ["r: testflight"],
                });
                labelSet.add("r: testflight");
              }
              if (haystack.includes("moltbook") && !hasMoltbookLabel) {
                await github.rest.issues.addLabels({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: issue.number,
                  labels: ["r: moltbook"],
                });
                labelSet.add("r: moltbook");
              }
            }

            const invalidLabel = "invalid";
            const spamLabel = "r: spam";
            const dirtyLabel = "dirty";
            const badBarnacleLabel = "bad-barnacle";
            const noisyPrMessage =
              "Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch.";

            if (pullRequest) {
              // `bad-barnacle` exempts PRs that Barnacle incorrectly marked dirty.
              if (labelSet.has(dirtyLabel) && !labelSet.has(badBarnacleLabel)) {
                await github.rest.issues.createComment({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: pullRequest.number,
                  body: noisyPrMessage,
                });
                await github.rest.issues.update({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: pullRequest.number,
                  state: "closed",
                });
                return;
              }
              const labelCount = labelSet.size;
              if (labelCount > 20) {
                await github.rest.issues.createComment({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: pullRequest.number,
                  body: noisyPrMessage,
                });
                await github.rest.issues.update({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: pullRequest.number,
                  state: "closed",
                });
                return;
              }
              if (labelSet.has(spamLabel)) {
                await github.rest.issues.update({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: pullRequest.number,
                  state: "closed",
                });
                await github.rest.issues.lock({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: pullRequest.number,
                  lock_reason: "spam",
                });
                return;
              }
              if (labelSet.has(invalidLabel)) {
                await github.rest.issues.update({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: pullRequest.number,
                  state: "closed",
                });
                return;
              }
            }

            if (issue && labelSet.has(spamLabel)) {
              await github.rest.issues.update({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: issue.number,
                state: "closed",
                state_reason: "not_planned",
              });
              await github.rest.issues.lock({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: issue.number,
                lock_reason: "spam",
              });
              return;
            }

            if (issue && labelSet.has(invalidLabel)) {
              await github.rest.issues.update({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: issue.number,
                state: "closed",
                state_reason: "not_planned",
              });
              return;
            }

            if (pullRequest && labelSet.has(activePrLimitOverrideLabel)) {
              labelSet.delete(activePrLimitLabel);
            }

            const rule = rules.find((item) => labelSet.has(item.label));
            if (!rule) {
              return;
            }

            const issueNumber = target.number;

            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: issueNumber,
              body: rule.message,
            });

            if (rule.close) {
              await github.rest.issues.update({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: issueNumber,
                state: "closed",
              });
            }

            if (rule.lock) {
              await github.rest.issues.lock({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: issueNumber,
                lock_reason: rule.lockReason ?? "resolved",
              });
            }
ci matrix perms .github/workflows/ci.yml
Triggers
push, pull_request
Runs on
blacksmith-16vcpu-ubuntu-2404, blacksmith-16vcpu-ubuntu-2404, blacksmith-16vcpu-ubuntu-2404, blacksmith-16vcpu-ubuntu-2404, blacksmith-16vcpu-ubuntu-2404, blacksmith-16vcpu-ubuntu-2404, blacksmith-16vcpu-ubuntu-2404, blacksmith-16vcpu-ubuntu-2404, blacksmith-16vcpu-ubuntu-2404, blacksmith-16vcpu-ubuntu-2404, blacksmith-16vcpu-ubuntu-2404, blacksmith-32vcpu-windows-2025, macos-latest, macos-latest, blacksmith-16vcpu-ubuntu-2404
Jobs
preflight, security-fast, build-artifacts, checks-fast, checks, extension-fast, check, check-additional, build-smoke, check-docs, skills-python, checks-windows, macos-node, macos-swift, android
Matrix
Actions
gradle/actions/setup-gradle
Commands
  • set -euo pipefail if [ "${{ github.event_name }}" = "push" ]; then BASE="${{ github.event.before }}" else BASE="${{ github.event.pull_request.base.sha }}" fi node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
  • node --input-type=module <<'EOF' import { appendFileSync } from "node:fs"; import { listChangedExtensionIds } from "./scripts/test-extension.mjs"; const extensionIds = listChangedExtensionIds({ base: process.env.BASE_SHA, head: "HEAD", fallbackBaseRef: process.env.BASE_REF, unavailableBaseBehavior: "all", }); const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) }); appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8"); appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8"); EOF
  • node scripts/ci-write-manifest-outputs.mjs --workflow ci
  • set -euo pipefail trusted_config="$RUNNER_TEMP/pre-commit-base.yaml" git show "${BASE_SHA}:.pre-commit-config.yaml" > "$trusted_config" echo "PRE_COMMIT_CONFIG_PATH=$trusted_config" >> "$GITHUB_ENV"
  • python -m pip install --upgrade pip python -m pip install pre-commit==4.2.0
  • pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" --all-files detect-private-key
  • set -euo pipefail if [ -z "${BASE_SHA:-}" ] || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ]; then echo "No usable base SHA detected; skipping zizmor." exit 0 fi if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then echo "Base SHA ${BASE_SHA} is unavailable; skipping zizmor." exit 0 fi mapfile -t workflow_files < <( git diff --name-only "${BASE_SHA}" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml' ) if [ "${#workflow_files[@]}" -eq 0 ]; then echo "No workflow changes detected; skipping zizmor." exit 0 fi printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}" pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
  • pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" --all-files pnpm-audit-prod
View raw YAML
name: CI

on:
  push:
    branches: [main]
  pull_request:
    types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]

permissions:
  contents: read

concurrency:
  group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.run_id) }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
  # Preflight: establish routing truth and planner-owned matrices once, then let
  # real work fan out from a single source of truth.
  preflight:
    if: github.event_name != 'pull_request' || !github.event.pull_request.draft
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    outputs:
      docs_only: ${{ steps.manifest.outputs.docs_only }}
      docs_changed: ${{ steps.manifest.outputs.docs_changed }}
      run_node: ${{ steps.manifest.outputs.run_node }}
      run_macos: ${{ steps.manifest.outputs.run_macos }}
      run_android: ${{ steps.manifest.outputs.run_android }}
      run_skills_python: ${{ steps.manifest.outputs.run_skills_python }}
      run_skills_python_job: ${{ steps.manifest.outputs.run_skills_python_job }}
      run_windows: ${{ steps.manifest.outputs.run_windows }}
      has_changed_extensions: ${{ steps.manifest.outputs.has_changed_extensions }}
      changed_extensions_matrix: ${{ steps.manifest.outputs.changed_extensions_matrix }}
      run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }}
      run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }}
      checks_fast_matrix: ${{ steps.manifest.outputs.checks_fast_matrix }}
      run_checks: ${{ steps.manifest.outputs.run_checks }}
      checks_matrix: ${{ steps.manifest.outputs.checks_matrix }}
      run_extension_fast: ${{ steps.manifest.outputs.run_extension_fast }}
      extension_fast_matrix: ${{ steps.manifest.outputs.extension_fast_matrix }}
      run_check: ${{ steps.manifest.outputs.run_check }}
      run_check_additional: ${{ steps.manifest.outputs.run_check_additional }}
      run_build_smoke: ${{ steps.manifest.outputs.run_build_smoke }}
      run_check_docs: ${{ steps.manifest.outputs.run_check_docs }}
      run_checks_windows: ${{ steps.manifest.outputs.run_checks_windows }}
      checks_windows_matrix: ${{ steps.manifest.outputs.checks_windows_matrix }}
      run_macos_node: ${{ steps.manifest.outputs.run_macos_node }}
      macos_node_matrix: ${{ steps.manifest.outputs.macos_node_matrix }}
      run_macos_swift: ${{ steps.manifest.outputs.run_macos_swift }}
      run_android_job: ${{ steps.manifest.outputs.run_android_job }}
      android_matrix: ${{ steps.manifest.outputs.android_matrix }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          fetch-depth: 1
          fetch-tags: false
          persist-credentials: false
          submodules: false

      - name: Ensure preflight base commit
        uses: ./.github/actions/ensure-base-commit
        with:
          base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
          fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}

      - name: Detect docs-only changes
        id: docs_scope
        uses: ./.github/actions/detect-docs-changes

      - name: Detect changed scopes
        id: changed_scope
        if: steps.docs_scope.outputs.docs_only != 'true'
        shell: bash
        run: |
          set -euo pipefail

          if [ "${{ github.event_name }}" = "push" ]; then
            BASE="${{ github.event.before }}"
          else
            BASE="${{ github.event.pull_request.base.sha }}"
          fi

          node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD

      - name: Setup Node environment
        if: steps.docs_scope.outputs.docs_only != 'true'
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          install-deps: "false"
          use-sticky-disk: "false"

      - name: Detect changed extensions
        id: changed_extensions
        if: steps.docs_scope.outputs.docs_only != 'true' && steps.changed_scope.outputs.run_node == 'true'
        env:
          BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
          BASE_REF: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
        run: |
          node --input-type=module <<'EOF'
          import { appendFileSync } from "node:fs";
          import { listChangedExtensionIds } from "./scripts/test-extension.mjs";

          const extensionIds = listChangedExtensionIds({
            base: process.env.BASE_SHA,
            head: "HEAD",
            fallbackBaseRef: process.env.BASE_REF,
            unavailableBaseBehavior: "all",
          });
          const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) });

          appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8");
          appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8");
          EOF

      - name: Build CI manifest
        id: manifest
        env:
          OPENCLAW_CI_DOCS_ONLY: ${{ steps.docs_scope.outputs.docs_only }}
          OPENCLAW_CI_DOCS_CHANGED: ${{ steps.docs_scope.outputs.docs_changed }}
          OPENCLAW_CI_RUN_NODE: ${{ steps.changed_scope.outputs.run_node || 'false' }}
          OPENCLAW_CI_RUN_MACOS: ${{ steps.changed_scope.outputs.run_macos || 'false' }}
          OPENCLAW_CI_RUN_ANDROID: ${{ steps.changed_scope.outputs.run_android || 'false' }}
          OPENCLAW_CI_RUN_WINDOWS: ${{ steps.changed_scope.outputs.run_windows || 'false' }}
          OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ steps.changed_scope.outputs.run_skills_python || 'false' }}
          OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }}
          OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: ${{ steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }}
        run: node scripts/ci-write-manifest-outputs.mjs --workflow ci

  # Run the fast security/SCM checks in parallel with scope detection so the
  # main Node jobs do not have to wait for Python/pre-commit setup.
  security-fast:
    if: github.event_name != 'pull_request' || !github.event.pull_request.draft
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    env:
      PRE_COMMIT_CACHE_KEY_SUFFIX: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          fetch-depth: 1
          fetch-tags: false
          persist-credentials: false
          submodules: false

      - name: Ensure security base commit
        uses: ./.github/actions/ensure-base-commit
        with:
          base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
          fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}

      - name: Prepare trusted pre-commit config
        if: github.event_name == 'pull_request'
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
        run: |
          set -euo pipefail
          trusted_config="$RUNNER_TEMP/pre-commit-base.yaml"
          git show "${BASE_SHA}:.pre-commit-config.yaml" > "$trusted_config"
          echo "PRE_COMMIT_CONFIG_PATH=$trusted_config" >> "$GITHUB_ENV"

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          install-deps: "false"
          use-sticky-disk: "false"

      - name: Setup Python
        id: setup-python
        uses: actions/setup-python@v6
        with:
          python-version: "3.12"
          cache: "pip"
          cache-dependency-path: |
            pyproject.toml
            .pre-commit-config.yaml
            .github/workflows/ci.yml

      - name: Restore pre-commit cache
        uses: actions/cache@v5
        with:
          path: ~/.cache/pre-commit
          key: pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}-${{ env.PRE_COMMIT_CACHE_KEY_SUFFIX }}
          restore-keys: |
            pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}-

      - name: Install pre-commit
        run: |
          python -m pip install --upgrade pip
          python -m pip install pre-commit==4.2.0

      - name: Detect committed private keys
        run: pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" --all-files detect-private-key

      - name: Audit changed GitHub workflows with zizmor
        env:
          BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
        run: |
          set -euo pipefail

          if [ -z "${BASE_SHA:-}" ] || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ]; then
            echo "No usable base SHA detected; skipping zizmor."
            exit 0
          fi

          if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
            echo "Base SHA ${BASE_SHA} is unavailable; skipping zizmor."
            exit 0
          fi

          mapfile -t workflow_files < <(
            git diff --name-only "${BASE_SHA}" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml'
          )
          if [ "${#workflow_files[@]}" -eq 0 ]; then
            echo "No workflow changes detected; skipping zizmor."
            exit 0
          fi

          printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}"
          pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"

      - name: Audit production dependencies
        run: pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" --all-files pnpm-audit-prod

  # Build dist once for Node-relevant changes and share it with downstream jobs.
  # Keep this overlapping with the fast correctness lanes so green PRs get heavy
  # test/build feedback sooner instead of waiting behind a full `check` pass.
  build-artifacts:
    needs: [preflight]
    if: needs.preflight.outputs.run_build_artifacts == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          submodules: false

      - name: Ensure secrets base commit (PR fast path)
        if: github.event_name == 'pull_request'
        uses: ./.github/actions/ensure-base-commit
        with:
          base-sha: ${{ github.event.pull_request.base.sha }}
          fetch-ref: ${{ github.event.pull_request.base.ref }}

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Build dist
        run: pnpm build

      - name: Build Control UI
        run: pnpm ui:build

      - name: Upload dist artifact
        uses: actions/upload-artifact@v7
        with:
          name: dist-build
          path: dist/
          retention-days: 1

      - name: Upload A2UI bundle artifact
        uses: actions/upload-artifact@v7
        with:
          name: canvas-a2ui-bundle
          path: src/canvas-host/a2ui/
          include-hidden-files: true
          retention-days: 1

  checks-fast:
    name: ${{ matrix.check_name }}
    needs: [preflight]
    if: needs.preflight.outputs.run_checks_fast == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_matrix) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Run ${{ matrix.task }} (${{ matrix.runtime }})
        env:
          TASK: ${{ matrix.task }}
        shell: bash
        run: |
          set -euo pipefail
          case "$TASK" in
            extensions)
              pnpm test:extensions
              ;;
            contracts|contracts-protocol)
              pnpm build
              pnpm test:contracts
              pnpm protocol:check
              ;;
            *)
              echo "Unsupported checks-fast task: $TASK" >&2
              exit 1
              ;;
          esac

  checks:
    name: ${{ matrix.check_name }}
    needs: [preflight, build-artifacts]
    if: always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.preflight.outputs.checks_matrix) }}
    steps:
      - name: Skip compatibility lanes on pull requests
        if: github.event_name == 'pull_request' && matrix.task == 'compat-node22'
        run: echo "Skipping push-only lane on pull requests."

      - name: Checkout
        if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          submodules: false

      - name: Setup Node environment
        if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
        uses: ./.github/actions/setup-node-env
        with:
          node-version: "${{ matrix.node_version || '24.x' }}"
          cache-key-suffix: "${{ matrix.cache_key_suffix || 'node24' }}"
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Configure Node test resources
        if: (github.event_name != 'pull_request' || matrix.task != 'compat-node22') && matrix.runtime == 'node' && (matrix.task == 'test' || matrix.task == 'channels' || matrix.task == 'compat-node22')
        env:
          TASK: ${{ matrix.task }}
          SHARD_COUNT: ${{ matrix.shard_count || '' }}
          SHARD_INDEX: ${{ matrix.shard_index || '' }}
        run: |
          # `pnpm test` runs `scripts/test-parallel.mjs`, which spawns multiple Node processes.
          # Default heap limits have been too low on Linux CI (V8 OOM near 4GB).
          echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
          echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
          if [ "$TASK" = "channels" ]; then
            echo "OPENCLAW_TEST_WORKERS=1" >> "$GITHUB_ENV"
            echo "OPENCLAW_TEST_ISOLATE=1" >> "$GITHUB_ENV"
          fi
          if [ -n "$SHARD_COUNT" ] && [ -n "$SHARD_INDEX" ]; then
            echo "OPENCLAW_TEST_SHARDS=$SHARD_COUNT" >> "$GITHUB_ENV"
            echo "OPENCLAW_TEST_SHARD_INDEX=$SHARD_INDEX" >> "$GITHUB_ENV"
          fi

      - name: Download dist artifact
        if: matrix.task == 'test'
        uses: actions/download-artifact@v8
        with:
          name: dist-build
          path: dist/

      - name: Download A2UI bundle artifact
        if: matrix.task == 'test' || matrix.task == 'channels'
        uses: actions/download-artifact@v8
        with:
          name: canvas-a2ui-bundle
          path: src/canvas-host/a2ui/

      - name: Run ${{ matrix.task }} (${{ matrix.runtime }})
        if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
        env:
          TASK: ${{ matrix.task }}
        shell: bash
        run: |
          set -euo pipefail
          case "$TASK" in
            test)
              pnpm test
              ;;
            channels)
              pnpm test:channels
              ;;
            compat-node22)
              pnpm build
              pnpm ui:build
              node openclaw.mjs --help
              node openclaw.mjs status --json --timeout 1
              pnpm test:build:singleton
              ;;
            *)
              echo "Unsupported checks task: $TASK" >&2
              exit 1
              ;;
          esac

  extension-fast:
    name: "extension-fast"
    needs: [preflight]
    if: needs.preflight.outputs.run_extension_fast == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.preflight.outputs.extension_fast_matrix) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Run changed extension tests
        env:
          OPENCLAW_CHANGED_EXTENSION: ${{ matrix.extension }}
        run: pnpm test:extension "$OPENCLAW_CHANGED_EXTENSION"

  # Types, lint, and format check.
  check:
    name: "check"
    needs: [preflight]
    if: always() && needs.preflight.outputs.run_check == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Check types and lint and oxfmt
        run: pnpm check

      - name: Strict TS build smoke
        run: pnpm build:strict-smoke

  check-additional:
    name: "check-additional"
    needs: [preflight]
    if: always() && needs.preflight.outputs.run_check_additional == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Run plugin extension boundary guard
        id: plugin_extension_boundary
        continue-on-error: true
        run: pnpm run lint:plugins:no-extension-imports

      - name: Run no-random-messaging guard
        id: no_random_messaging
        continue-on-error: true
        run: pnpm run lint:tmp:no-random-messaging

      - name: Run channel-agnostic boundary guard
        id: channel_agnostic_boundaries
        continue-on-error: true
        run: pnpm run lint:tmp:channel-agnostic-boundaries

      - name: Run no-raw-channel-fetch guard
        id: no_raw_channel_fetch
        continue-on-error: true
        run: pnpm run lint:tmp:no-raw-channel-fetch

      - name: Run ingress owner guard
        id: ingress_owner
        continue-on-error: true
        run: pnpm run lint:agent:ingress-owner

      - name: Run no-register-http-handler guard
        id: no_register_http_handler
        continue-on-error: true
        run: pnpm run lint:plugins:no-register-http-handler

      - name: Run no-monolithic plugin-sdk entry import guard
        id: no_monolithic_plugin_sdk_entry_imports
        continue-on-error: true
        run: pnpm run lint:plugins:no-monolithic-plugin-sdk-entry-imports

      - name: Run no-extension-src-imports guard
        id: no_extension_src_imports
        continue-on-error: true
        run: pnpm run lint:plugins:no-extension-src-imports

      - name: Run no-extension-test-core-imports guard
        id: no_extension_test_core_imports
        continue-on-error: true
        run: pnpm run lint:plugins:no-extension-test-core-imports

      - name: Run plugin-sdk subpaths exported guard
        id: plugin_sdk_subpaths_exported
        continue-on-error: true
        run: pnpm run lint:plugins:plugin-sdk-subpaths-exported

      - name: Run web search provider boundary guard
        id: web_search_provider_boundary
        continue-on-error: true
        run: pnpm run lint:web-search-provider-boundaries

      - name: Run extension src boundary guard
        id: extension_src_outside_plugin_sdk_boundary
        continue-on-error: true
        run: pnpm run lint:extensions:no-src-outside-plugin-sdk

      - name: Run extension plugin-sdk-internal guard
        id: extension_plugin_sdk_internal_boundary
        continue-on-error: true
        run: pnpm run lint:extensions:no-plugin-sdk-internal

      - name: Run extension relative-outside-package guard
        id: extension_relative_outside_package_boundary
        continue-on-error: true
        run: pnpm run lint:extensions:no-relative-outside-package

      - name: Enforce safe external URL opening policy
        id: no_raw_window_open
        continue-on-error: true
        run: pnpm lint:ui:no-raw-window-open

      - name: Run gateway watch regression harness
        id: gateway_watch_regression
        continue-on-error: true
        run: pnpm test:gateway:watch-regression

      - name: Upload gateway watch regression artifacts
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: gateway-watch-regression
          path: .local/gateway-watch-regression/
          retention-days: 7

      - name: Fail if any additional check failed
        if: always()
        env:
          PLUGIN_EXTENSION_BOUNDARY_OUTCOME: ${{ steps.plugin_extension_boundary.outcome }}
          NO_RANDOM_MESSAGING_OUTCOME: ${{ steps.no_random_messaging.outcome }}
          CHANNEL_AGNOSTIC_BOUNDARIES_OUTCOME: ${{ steps.channel_agnostic_boundaries.outcome }}
          NO_RAW_CHANNEL_FETCH_OUTCOME: ${{ steps.no_raw_channel_fetch.outcome }}
          INGRESS_OWNER_OUTCOME: ${{ steps.ingress_owner.outcome }}
          NO_REGISTER_HTTP_HANDLER_OUTCOME: ${{ steps.no_register_http_handler.outcome }}
          NO_MONOLITHIC_PLUGIN_SDK_ENTRY_IMPORTS_OUTCOME: ${{ steps.no_monolithic_plugin_sdk_entry_imports.outcome }}
          NO_EXTENSION_SRC_IMPORTS_OUTCOME: ${{ steps.no_extension_src_imports.outcome }}
          NO_EXTENSION_TEST_CORE_IMPORTS_OUTCOME: ${{ steps.no_extension_test_core_imports.outcome }}
          PLUGIN_SDK_SUBPATHS_EXPORTED_OUTCOME: ${{ steps.plugin_sdk_subpaths_exported.outcome }}
          WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME: ${{ steps.web_search_provider_boundary.outcome }}
          EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME: ${{ steps.extension_src_outside_plugin_sdk_boundary.outcome }}
          EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME: ${{ steps.extension_plugin_sdk_internal_boundary.outcome }}
          EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME: ${{ steps.extension_relative_outside_package_boundary.outcome }}
          NO_RAW_WINDOW_OPEN_OUTCOME: ${{ steps.no_raw_window_open.outcome }}
          GATEWAY_WATCH_REGRESSION_OUTCOME: ${{ steps.gateway_watch_regression.outcome }}
        run: |
          failures=0
          for result in \
            "plugin-extension-boundary|$PLUGIN_EXTENSION_BOUNDARY_OUTCOME" \
            "lint:tmp:no-random-messaging|$NO_RANDOM_MESSAGING_OUTCOME" \
            "lint:tmp:channel-agnostic-boundaries|$CHANNEL_AGNOSTIC_BOUNDARIES_OUTCOME" \
            "lint:tmp:no-raw-channel-fetch|$NO_RAW_CHANNEL_FETCH_OUTCOME" \
            "lint:agent:ingress-owner|$INGRESS_OWNER_OUTCOME" \
            "lint:plugins:no-register-http-handler|$NO_REGISTER_HTTP_HANDLER_OUTCOME" \
            "lint:plugins:no-monolithic-plugin-sdk-entry-imports|$NO_MONOLITHIC_PLUGIN_SDK_ENTRY_IMPORTS_OUTCOME" \
            "lint:plugins:no-extension-src-imports|$NO_EXTENSION_SRC_IMPORTS_OUTCOME" \
            "lint:plugins:no-extension-test-core-imports|$NO_EXTENSION_TEST_CORE_IMPORTS_OUTCOME" \
            "lint:plugins:plugin-sdk-subpaths-exported|$PLUGIN_SDK_SUBPATHS_EXPORTED_OUTCOME" \
            "web-search-provider-boundary|$WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME" \
            "extension-src-outside-plugin-sdk-boundary|$EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME" \
            "extension-plugin-sdk-internal-boundary|$EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME" \
            "extension-relative-outside-package-boundary|$EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME" \
            "lint:ui:no-raw-window-open|$NO_RAW_WINDOW_OPEN_OUTCOME" \
            "gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME"; do
            name="${result%%|*}"
            outcome="${result#*|}"
            if [ "$outcome" != "success" ]; then
              echo "::error title=${name} failed::${name} outcome: ${outcome}"
              failures=1
            fi
          done

          exit "$failures"

  build-smoke:
    name: "build-smoke"
    needs: [preflight, build-artifacts]
    if: always() && needs.preflight.outputs.run_build_smoke == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Download dist artifact
        if: github.event_name == 'push'
        uses: actions/download-artifact@v8
        with:
          name: dist-build
          path: dist/

      - name: Build dist
        if: github.event_name != 'push'
        run: pnpm build

      - name: Smoke test CLI launcher help
        run: node openclaw.mjs --help

      - name: Smoke test CLI launcher status json
        run: node openclaw.mjs status --json --timeout 1

      - name: Smoke test built bundled plugin singleton
        run: pnpm test:build:singleton

      - name: Check CLI startup memory
        run: pnpm test:startup:memory

  # Validate docs (format, lint, broken links) only when docs files changed.
  check-docs:
    needs: [preflight]
    if: needs.preflight.outputs.run_check_docs == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Check docs
        run: pnpm check:docs

  skills-python:
    needs: [preflight]
    if: needs.preflight.outputs.run_skills_python_job == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          submodules: false

      - name: Setup Python
        uses: actions/setup-python@v6
        with:
          python-version: "3.12"

      - name: Install Python tooling
        run: |
          python -m pip install --upgrade pip
          python -m pip install pytest ruff pyyaml

      - name: Lint Python skill scripts
        run: python -m ruff check skills

      - name: Test skill Python scripts
        run: python -m pytest -q skills

  checks-windows:
    name: ${{ matrix.check_name }}
    needs: [preflight, build-artifacts]
    if: always() && needs.preflight.outputs.run_checks_windows == 'true' && needs.build-artifacts.result == 'success'
    runs-on: blacksmith-32vcpu-windows-2025
    timeout-minutes: 20
    env:
      NODE_OPTIONS: --max-old-space-size=6144
      # Keep total concurrency predictable on the 32 vCPU runner.
      # Windows shard 2 has shown intermittent instability at 2 workers.
      OPENCLAW_TEST_WORKERS: 1
    defaults:
      run:
        shell: bash
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.preflight.outputs.checks_windows_matrix) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          submodules: false

      - name: Try to exclude workspace from Windows Defender (best-effort)
        shell: pwsh
        run: |
          $cmd = Get-Command Add-MpPreference -ErrorAction SilentlyContinue
          if (-not $cmd) {
            Write-Host "Add-MpPreference not available, skipping Defender exclusions."
            exit 0
          }

          try {
            # Defender sometimes intercepts process spawning (vitest workers). If this fails
            # (eg hardened images), keep going and rely on worker limiting above.
            Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE" -ErrorAction Stop
            Add-MpPreference -ExclusionProcess "node.exe" -ErrorAction Stop
            Write-Host "Defender exclusions applied."
          } catch {
            Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
          }

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 24.x
          check-latest: false

      - name: Setup pnpm + cache store
        uses: ./.github/actions/setup-pnpm-store-cache
        with:
          pnpm-version: "10.23.0"
          cache-key-suffix: "node24"
          # Sticky disk mount currently retries/fails on every shard and adds ~50s
          # before install while still yielding zero pnpm store reuse.
          # Try exact-key actions/cache restores instead to recover store reuse
          # without the sticky-disk mount penalty.
          use-sticky-disk: "false"
          use-restore-keys: "false"
          use-actions-cache: "true"

      - name: Runtime versions
        run: |
          node -v
          npm -v
          pnpm -v

      - name: Capture node path
        run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"

      - name: Install dependencies
        env:
          CI: true
        run: |
          export PATH="$NODE_BIN:$PATH"
          which node
          node -v
          pnpm -v
          # Persist Windows-native postinstall outputs in the pnpm store so restored
          # caches can skip repeated rebuild/download work on later shards/runs.
          pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true || pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true

      - name: Configure test shard (Windows)
        if: matrix.task == 'test'
        env:
          SHARD_COUNT: ${{ matrix.shard_count }}
          SHARD_INDEX: ${{ matrix.shard_index }}
        run: |
          echo "OPENCLAW_TEST_SHARDS=$SHARD_COUNT" >> "$GITHUB_ENV"
          echo "OPENCLAW_TEST_SHARD_INDEX=$SHARD_INDEX" >> "$GITHUB_ENV"

      - name: Download dist artifact
        if: matrix.task == 'test'
        uses: actions/download-artifact@v8
        with:
          name: dist-build
          path: dist/

      - name: Download A2UI bundle artifact
        if: matrix.task == 'test'
        uses: actions/download-artifact@v8
        with:
          name: canvas-a2ui-bundle
          path: src/canvas-host/a2ui/

      - name: Run ${{ matrix.task }} (${{ matrix.runtime }})
        env:
          TASK: ${{ matrix.task }}
        shell: bash
        run: |
          set -euo pipefail
          case "$TASK" in
            test)
              pnpm test
              ;;
            *)
              echo "Unsupported Windows checks task: $TASK" >&2
              exit 1
              ;;
          esac

  macos-node:
    name: ${{ matrix.check_name }}
    needs: [preflight, build-artifacts]
    if: always() && needs.preflight.outputs.run_macos_node == 'true' && needs.build-artifacts.result == 'success'
    runs-on: macos-latest
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.preflight.outputs.macos_node_matrix) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"

      - name: Download dist artifact
        uses: actions/download-artifact@v8
        with:
          name: dist-build
          path: dist/

      - name: Download A2UI bundle artifact
        uses: actions/download-artifact@v8
        with:
          name: canvas-a2ui-bundle
          path: src/canvas-host/a2ui/

      - name: Configure test shard (macOS)
        env:
          SHARD_COUNT: ${{ matrix.shard_count }}
          SHARD_INDEX: ${{ matrix.shard_index }}
        run: |
          echo "OPENCLAW_TEST_SHARDS=$SHARD_COUNT" >> "$GITHUB_ENV"
          echo "OPENCLAW_TEST_SHARD_INDEX=$SHARD_INDEX" >> "$GITHUB_ENV"

      - name: TS tests (macOS)
        env:
          NODE_OPTIONS: --max-old-space-size=4096
          TASK: ${{ matrix.task }}
        shell: bash
        run: |
          set -euo pipefail
          case "$TASK" in
            test)
              pnpm test
              ;;
            *)
              echo "Unsupported macOS node task: $TASK" >&2
              exit 1
              ;;
          esac

  macos-swift:
    name: "macos-swift"
    needs: [preflight]
    if: needs.preflight.outputs.run_macos_swift == 'true'
    runs-on: macos-latest
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          submodules: false

      - name: Select Xcode 26.1
        run: |
          sudo xcode-select -s /Applications/Xcode_26.1.app
          xcodebuild -version

      - name: Install XcodeGen / SwiftLint / SwiftFormat
        run: brew install xcodegen swiftlint swiftformat

      - name: Cache SwiftPM
        uses: actions/cache@v5
        with:
          path: ~/Library/Caches/org.swift.swiftpm
          key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }}
          restore-keys: |
            ${{ runner.os }}-swiftpm-

      - name: Show toolchain
        run: |
          sw_vers
          xcodebuild -version
          swift --version

      - name: Swift lint
        run: |
          swiftlint --config .swiftlint.yml
          swiftformat --lint apps/macos/Sources --config .swiftformat

      - name: Swift build (release)
        run: |
          set -euo pipefail
          for attempt in 1 2 3; do
            if swift build --package-path apps/macos --configuration release; then
              exit 0
            fi
            echo "swift build failed (attempt $attempt/3). Retrying…"
            sleep $((attempt * 20))
          done
          exit 1

      - name: Swift test
        run: |
          set -euo pipefail
          for attempt in 1 2 3; do
            if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
              exit 0
            fi
            echo "swift test failed (attempt $attempt/3). Retrying…"
            sleep $((attempt * 20))
          done
          exit 1

  android:
    name: ${{ matrix.check_name }}
    needs: [preflight]
    if: needs.preflight.outputs.run_android_job == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.preflight.outputs.android_matrix) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          persist-credentials: false
          submodules: false

      - name: Setup Java
        uses: actions/setup-java@v5
        with:
          distribution: temurin
          # Keep sdkmanager on the stable JDK path for Linux CI runners.
          java-version: 17

      - name: Setup Android SDK cmdline-tools
        run: |
          set -euo pipefail
          ANDROID_SDK_ROOT="$HOME/.android-sdk"
          CMDLINE_TOOLS_VERSION="12266719"
          ARCHIVE="commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip"
          URL="https://dl.google.com/android/repository/${ARCHIVE}"

          mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
          curl -fsSL "$URL" -o "/tmp/${ARCHIVE}"
          rm -rf "$ANDROID_SDK_ROOT/cmdline-tools/latest"
          unzip -q "/tmp/${ARCHIVE}" -d "$ANDROID_SDK_ROOT/cmdline-tools"
          mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest"

          echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
          echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
          echo "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
          echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH"

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5
        with:
          gradle-version: 8.11.1

      - name: Install Android SDK packages
        run: |
          yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null
          sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --install \
            "platform-tools" \
            "platforms;android-36" \
            "build-tools;36.0.0"

      - name: Run Android ${{ matrix.task }}
        working-directory: apps/android
        env:
          TASK: ${{ matrix.task }}
        shell: bash
        run: |
          set -euo pipefail
          case "$TASK" in
            test-play)
              ./gradlew --no-daemon :app:testPlayDebugUnitTest
              ;;
            test-third-party)
              ./gradlew --no-daemon :app:testThirdPartyDebugUnitTest
              ;;
            build-play)
              ./gradlew --no-daemon :app:assemblePlayDebug
              ;;
            build-third-party)
              ./gradlew --no-daemon :app:assembleThirdPartyDebug
              ;;
            *)
              echo "Unsupported Android task: $TASK" >&2
              exit 1
              ;;
          esac
ci-bun matrix .github/workflows/ci-bun.yml
Triggers
push
Runs on
blacksmith-16vcpu-ubuntu-2404, blacksmith-16vcpu-ubuntu-2404, blacksmith-16vcpu-ubuntu-2404
Jobs
preflight, build-bun-artifacts, bun-checks
Matrix
Commands
  • node scripts/ci-write-manifest-outputs.mjs --workflow ci-bun
  • pnpm canvas:a2ui:bundle
  • set -euo pipefail OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts --shard "$SHARD_INDEX/$SHARD_COUNT"
View raw YAML
name: CI Bun

on:
  push:
    branches: [main]

concurrency:
  group: ci-bun-push-${{ github.run_id }}
  cancel-in-progress: false

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
  preflight:
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    outputs:
      run_bun_checks: ${{ steps.manifest.outputs.run_bun_checks }}
      bun_checks_matrix: ${{ steps.manifest.outputs.bun_checks_matrix }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          install-deps: "false"
          use-sticky-disk: "false"

      - name: Build Bun CI manifest
        id: manifest
        env:
          OPENCLAW_CI_DOCS_ONLY: "false"
          OPENCLAW_CI_DOCS_CHANGED: "false"
          OPENCLAW_CI_RUN_NODE: "true"
          OPENCLAW_CI_RUN_MACOS: "false"
          OPENCLAW_CI_RUN_ANDROID: "false"
          OPENCLAW_CI_RUN_WINDOWS: "false"
          OPENCLAW_CI_RUN_SKILLS_PYTHON: "false"
          OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: "false"
          OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: '{"include":[]}'
        run: node scripts/ci-write-manifest-outputs.mjs --workflow ci-bun

  build-bun-artifacts:
    needs: [preflight]
    if: needs.preflight.outputs.run_bun_checks == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Build A2UI bundle
        run: pnpm canvas:a2ui:bundle

      - name: Upload A2UI bundle artifact
        uses: actions/upload-artifact@v7
        with:
          name: canvas-a2ui-bundle
          path: src/canvas-host/a2ui/
          include-hidden-files: true
          retention-days: 1

  bun-checks:
    name: ${{ matrix.check_name }}
    needs: [preflight, build-bun-artifacts]
    if: needs.preflight.outputs.run_bun_checks == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.preflight.outputs.bun_checks_matrix) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          submodules: false

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "true"
          use-sticky-disk: "false"

      - name: Download A2UI bundle artifact
        uses: actions/download-artifact@v8
        with:
          name: canvas-a2ui-bundle
          path: src/canvas-host/a2ui/

      - name: Run Bun test shard
        env:
          SHARD_COUNT: ${{ matrix.shard_count }}
          SHARD_INDEX: ${{ matrix.shard_index }}
        shell: bash
        run: |
          set -euo pipefail
          OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts --shard "$SHARD_INDEX/$SHARD_COUNT"
codeql matrix perms security .github/workflows/codeql.yml
Triggers
workflow_dispatch
Runs on
${{ matrix.runs_on }}
Jobs
analyze
Matrix
include, include.config_file, include.language, include.needs_autobuild, include.needs_java, include.needs_manual_build, include.needs_node, include.needs_python, include.needs_swift_tools, include.runs_on→ , ./.github/codeql/codeql-javascript-typescript.yml, False, True, actions, blacksmith-16vcpu-ubuntu-2404, java-kotlin, javascript-typescript, macos-latest, python, swift
Actions
github/codeql-action/init, github/codeql-action/autobuild, github/codeql-action/analyze
Commands
  • sudo xcode-select -s /Applications/Xcode_26.1.app xcodebuild -version brew install xcodegen swiftlint swiftformat swift --version
  • ./gradlew --no-daemon :app:assemblePlayDebug
  • set -euo pipefail swift build --package-path apps/macos --configuration release cd apps/ios xcodegen generate xcodebuild build \ -project OpenClaw.xcodeproj \ -scheme OpenClaw \ -destination "generic/platform=iOS Simulator" \ CODE_SIGNING_ALLOWED=NO
View raw YAML
name: CodeQL

on:
  workflow_dispatch:

concurrency:
  group: codeql-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

permissions:
  actions: read
  contents: read
  security-events: write

jobs:
  analyze:
    name: Analyze (${{ matrix.language }})
    runs-on: ${{ matrix.runs_on }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - language: javascript-typescript
            runs_on: blacksmith-16vcpu-ubuntu-2404
            needs_node: true
            needs_python: false
            needs_java: false
            needs_swift_tools: false
            needs_manual_build: false
            needs_autobuild: false
            config_file: ./.github/codeql/codeql-javascript-typescript.yml
          - language: actions
            runs_on: blacksmith-16vcpu-ubuntu-2404
            needs_node: false
            needs_python: false
            needs_java: false
            needs_swift_tools: false
            needs_manual_build: false
            needs_autobuild: false
            config_file: ""
          - language: python
            runs_on: blacksmith-16vcpu-ubuntu-2404
            needs_node: false
            needs_python: true
            needs_java: false
            needs_swift_tools: false
            needs_manual_build: false
            needs_autobuild: false
            config_file: ""
          - language: java-kotlin
            runs_on: blacksmith-16vcpu-ubuntu-2404
            needs_node: false
            needs_python: false
            needs_java: true
            needs_swift_tools: false
            needs_manual_build: true
            needs_autobuild: false
            config_file: ""
          - language: swift
            runs_on: macos-latest
            needs_node: false
            needs_python: false
            needs_java: false
            needs_swift_tools: true
            needs_manual_build: true
            needs_autobuild: false
            config_file: ""
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          submodules: false

      - name: Setup Node environment
        if: matrix.needs_node
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Setup Python
        if: matrix.needs_python
        uses: actions/setup-python@v6
        with:
          python-version: "3.12"

      - name: Setup Java
        if: matrix.needs_java
        uses: actions/setup-java@v5
        with:
          distribution: temurin
          java-version: "21"

      - name: Setup Swift build tools
        if: matrix.needs_swift_tools
        run: |
          sudo xcode-select -s /Applications/Xcode_26.1.app
          xcodebuild -version
          brew install xcodegen swiftlint swiftformat
          swift --version

      - name: Initialize CodeQL
        uses: github/codeql-action/init@v4
        with:
          languages: ${{ matrix.language }}
          queries: security-and-quality
          config-file: ${{ matrix.config_file || '' }}

      - name: Autobuild
        if: matrix.needs_autobuild
        uses: github/codeql-action/autobuild@v4

      - name: Build Android for CodeQL
        if: matrix.language == 'java-kotlin'
        working-directory: apps/android
        run: ./gradlew --no-daemon :app:assemblePlayDebug

      - name: Build Swift for CodeQL
        if: matrix.language == 'swift'
        run: |
          set -euo pipefail
          swift build --package-path apps/macos --configuration release
          cd apps/ios
          xcodegen generate
          xcodebuild build \
            -project OpenClaw.xcodeproj \
            -scheme OpenClaw \
            -destination "generic/platform=iOS Simulator" \
            CODE_SIGNING_ALLOWED=NO

      - name: Analyze
        uses: github/codeql-action/analyze@v4
        with:
          category: "/language:${{ matrix.language }}"
docker-release .github/workflows/docker-release.yml
Triggers
push, workflow_dispatch
Runs on
ubuntu-24.04, ubuntu-24.04, ubuntu-24.04, ubuntu-24.04-arm, ubuntu-24.04
Jobs
validate_manual_backfill, approve_manual_backfill, build-amd64, build-arm64, create-manifest
Actions
docker/setup-buildx-action, docker/login-action, docker/build-push-action, docker/build-push-action, docker/setup-buildx-action, docker/login-action, docker/build-push-action, docker/build-push-action, docker/login-action
Commands
  • set -euo pipefail if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-beta\.[1-9][0-9]*)?$ ]]; then echo "Invalid release tag: ${RELEASE_TAG}" exit 1 fi
  • echo "Approved Docker backfill for $RELEASE_TAG"
  • set -euo pipefail tags=() slim_tags=() if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then tags+=("${IMAGE}:main-amd64") slim_tags+=("${IMAGE}:main-slim-amd64") fi if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then version="${SOURCE_REF#refs/tags/v}" tags+=("${IMAGE}:${version}-amd64") slim_tags+=("${IMAGE}:${version}-slim-amd64") fi if [[ ${#tags[@]} -eq 0 ]]; then echo "::error::No amd64 tags resolved for ref ${SOURCE_REF}" exit 1 fi { echo "value<<EOF" printf "%s\n" "${tags[@]}" echo "EOF" } >> "$GITHUB_OUTPUT" { echo "slim<<EOF" printf "%s\n" "${slim_tags[@]}" echo "EOF" } >> "$GITHUB_OUTPUT"
  • set -euo pipefail source_sha="$(git rev-parse HEAD)" version="${source_sha}" if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then version="main" fi if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then version="${SOURCE_REF#refs/tags/v}" fi created="$(date -u +%Y-%m-%dT%H:%M:%SZ)" { echo "value<<EOF" echo "org.opencontainers.image.revision=${source_sha}" echo "org.opencontainers.image.version=${version}" echo "org.opencontainers.image.created=${created}" echo "EOF" } >> "$GITHUB_OUTPUT"
  • set -euo pipefail tags=() slim_tags=() if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then tags+=("${IMAGE}:main-arm64") slim_tags+=("${IMAGE}:main-slim-arm64") fi if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then version="${SOURCE_REF#refs/tags/v}" tags+=("${IMAGE}:${version}-arm64") slim_tags+=("${IMAGE}:${version}-slim-arm64") fi if [[ ${#tags[@]} -eq 0 ]]; then echo "::error::No arm64 tags resolved for ref ${SOURCE_REF}" exit 1 fi { echo "value<<EOF" printf "%s\n" "${tags[@]}" echo "EOF" } >> "$GITHUB_OUTPUT" { echo "slim<<EOF" printf "%s\n" "${slim_tags[@]}" echo "EOF" } >> "$GITHUB_OUTPUT"
  • set -euo pipefail source_sha="$(git rev-parse HEAD)" version="${source_sha}" if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then version="main" fi if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then version="${SOURCE_REF#refs/tags/v}" fi created="$(date -u +%Y-%m-%dT%H:%M:%SZ)" { echo "value<<EOF" echo "org.opencontainers.image.revision=${source_sha}" echo "org.opencontainers.image.version=${version}" echo "org.opencontainers.image.created=${created}" echo "EOF" } >> "$GITHUB_OUTPUT"
  • set -euo pipefail tags=() slim_tags=() if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then tags+=("${IMAGE}:main") slim_tags+=("${IMAGE}:main-slim") fi if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then version="${SOURCE_REF#refs/tags/v}" tags+=("${IMAGE}:${version}") slim_tags+=("${IMAGE}:${version}-slim") # Manual backfills should only republish the requested version tags. if [[ "${IS_MANUAL_BACKFILL}" != "1" && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then tags+=("${IMAGE}:latest") slim_tags+=("${IMAGE}:slim") fi fi if [[ ${#tags[@]} -eq 0 ]]; then echo "::error::No manifest tags resolved for ref ${SOURCE_REF}" exit 1 fi { echo "value<<EOF" printf "%s\n" "${tags[@]}" echo "EOF" } >> "$GITHUB_OUTPUT" { echo "slim<<EOF" printf "%s\n" "${slim_tags[@]}" echo "EOF" } >> "$GITHUB_OUTPUT"
  • set -euo pipefail mapfile -t tags <<< "${{ steps.tags.outputs.value }}" args=() for tag in "${tags[@]}"; do [ -z "$tag" ] && continue args+=("-t" "$tag") done docker buildx imagetools create "${args[@]}" \ ${{ needs.build-amd64.outputs.digest }} \ ${{ needs.build-arm64.outputs.digest }}
View raw YAML
name: Docker Release

on:
  push:
    tags:
      - "v*"
    paths-ignore:
      - "docs/**"
      - "**/*.md"
      - "**/*.mdx"
      - ".agents/**"
      - "skills/**"
  workflow_dispatch:
    inputs:
      tag:
        description: Existing release tag to backfill (for example v2026.3.22)
        required: true
        type: string

concurrency:
  group: ${{ github.event_name == 'workflow_dispatch' && format('docker-release-manual-{0}', inputs.tag) || format('docker-release-push-{0}', github.run_id) }}
  cancel-in-progress: false

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  validate_manual_backfill:
    if: github.event_name == 'workflow_dispatch'
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    steps:
      - name: Validate tag input format
        env:
          RELEASE_TAG: ${{ inputs.tag }}
        run: |
          set -euo pipefail
          if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-beta\.[1-9][0-9]*)?$ ]]; then
            echo "Invalid release tag: ${RELEASE_TAG}"
            exit 1
          fi

      - name: Checkout selected tag
        uses: actions/checkout@v6
        with:
          ref: refs/tags/${{ inputs.tag }}
          fetch-depth: 0

  approve_manual_backfill:
    if: github.event_name == 'workflow_dispatch'
    needs: validate_manual_backfill
    # WARNING: KEEP MANUAL BACKFILLS GATED BY THE docker-release ENVIRONMENT.
    runs-on: ubuntu-24.04
    environment: docker-release
    steps:
      - name: Approve Docker backfill
        env:
          RELEASE_TAG: ${{ inputs.tag }}
        run: echo "Approved Docker backfill for $RELEASE_TAG"

  # KEEP THIS WORKFLOW ON GITHUB-HOSTED RUNNERS.
  # DO NOT MOVE IT BACK TO BLACKSMITH WITHOUT RE-VALIDATING TAG BUILDS AND BACKFILLS.
  # Build amd64 images (default + slim share the build stage cache)
  build-amd64:
    needs: [approve_manual_backfill]
    if: ${{ always() && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
    # WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS.
    runs-on: ubuntu-24.04
    permissions:
      packages: write
      contents: read
    outputs:
      digest: ${{ steps.build.outputs.digest }}
      slim-digest: ${{ steps.build-slim.outputs.digest }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
          fetch-depth: 0

      - name: Set up Docker Builder
        uses: docker/setup-buildx-action@v4

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Resolve image tags (amd64)
        id: tags
        shell: bash
        env:
          IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
        run: |
          set -euo pipefail
          tags=()
          slim_tags=()
          if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
            tags+=("${IMAGE}:main-amd64")
            slim_tags+=("${IMAGE}:main-slim-amd64")
          fi
          if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
            version="${SOURCE_REF#refs/tags/v}"
            tags+=("${IMAGE}:${version}-amd64")
            slim_tags+=("${IMAGE}:${version}-slim-amd64")
          fi
          if [[ ${#tags[@]} -eq 0 ]]; then
            echo "::error::No amd64 tags resolved for ref ${SOURCE_REF}"
            exit 1
          fi
          {
            echo "value<<EOF"
            printf "%s\n" "${tags[@]}"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"
          {
            echo "slim<<EOF"
            printf "%s\n" "${slim_tags[@]}"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"

      - name: Resolve OCI labels (amd64)
        id: labels
        shell: bash
        env:
          SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
        run: |
          set -euo pipefail
          source_sha="$(git rev-parse HEAD)"
          version="${source_sha}"
          if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
            version="main"
          fi
          if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
            version="${SOURCE_REF#refs/tags/v}"
          fi
          created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
          {
            echo "value<<EOF"
            echo "org.opencontainers.image.revision=${source_sha}"
            echo "org.opencontainers.image.version=${version}"
            echo "org.opencontainers.image.created=${created}"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"

      - name: Build and push amd64 image
        id: build
        # WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64
          cache-from: type=gha,scope=docker-release-amd64
          cache-to: type=gha,mode=max,scope=docker-release-amd64
          tags: ${{ steps.tags.outputs.value }}
          labels: ${{ steps.labels.outputs.value }}
          provenance: false
          push: true

      - name: Build and push amd64 slim image
        id: build-slim
        # WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64
          cache-from: type=gha,scope=docker-release-amd64
          cache-to: type=gha,mode=max,scope=docker-release-amd64
          build-args: |
            OPENCLAW_VARIANT=slim
          tags: ${{ steps.tags.outputs.slim }}
          labels: ${{ steps.labels.outputs.value }}
          provenance: false
          push: true

  # Build arm64 images (default + slim share the build stage cache)
  build-arm64:
    needs: [approve_manual_backfill]
    if: ${{ always() && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
    # WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS.
    runs-on: ubuntu-24.04-arm
    permissions:
      packages: write
      contents: read
    outputs:
      digest: ${{ steps.build.outputs.digest }}
      slim-digest: ${{ steps.build-slim.outputs.digest }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
          fetch-depth: 0

      - name: Set up Docker Builder
        uses: docker/setup-buildx-action@v4

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Resolve image tags (arm64)
        id: tags
        shell: bash
        env:
          IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
        run: |
          set -euo pipefail
          tags=()
          slim_tags=()
          if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
            tags+=("${IMAGE}:main-arm64")
            slim_tags+=("${IMAGE}:main-slim-arm64")
          fi
          if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
            version="${SOURCE_REF#refs/tags/v}"
            tags+=("${IMAGE}:${version}-arm64")
            slim_tags+=("${IMAGE}:${version}-slim-arm64")
          fi
          if [[ ${#tags[@]} -eq 0 ]]; then
            echo "::error::No arm64 tags resolved for ref ${SOURCE_REF}"
            exit 1
          fi
          {
            echo "value<<EOF"
            printf "%s\n" "${tags[@]}"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"
          {
            echo "slim<<EOF"
            printf "%s\n" "${slim_tags[@]}"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"

      - name: Resolve OCI labels (arm64)
        id: labels
        shell: bash
        env:
          SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
        run: |
          set -euo pipefail
          source_sha="$(git rev-parse HEAD)"
          version="${source_sha}"
          if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
            version="main"
          fi
          if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
            version="${SOURCE_REF#refs/tags/v}"
          fi
          created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
          {
            echo "value<<EOF"
            echo "org.opencontainers.image.revision=${source_sha}"
            echo "org.opencontainers.image.version=${version}"
            echo "org.opencontainers.image.created=${created}"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"

      - name: Build and push arm64 image
        id: build
        # WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/arm64
          cache-from: type=gha,scope=docker-release-arm64
          cache-to: type=gha,mode=max,scope=docker-release-arm64
          tags: ${{ steps.tags.outputs.value }}
          labels: ${{ steps.labels.outputs.value }}
          provenance: false
          push: true

      - name: Build and push arm64 slim image
        id: build-slim
        # WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/arm64
          cache-from: type=gha,scope=docker-release-arm64
          cache-to: type=gha,mode=max,scope=docker-release-arm64
          build-args: |
            OPENCLAW_VARIANT=slim
          tags: ${{ steps.tags.outputs.slim }}
          labels: ${{ steps.labels.outputs.value }}
          provenance: false
          push: true

  # Create multi-platform manifests
  create-manifest:
    needs: [approve_manual_backfill, build-amd64, build-arm64]
    if: ${{ always() && needs.build-amd64.result == 'success' && needs.build-arm64.result == 'success' && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
    # WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS.
    runs-on: ubuntu-24.04
    permissions:
      packages: write
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
          fetch-depth: 0

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Resolve manifest tags
        id: tags
        shell: bash
        env:
          IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
          IS_MANUAL_BACKFILL: ${{ github.event_name == 'workflow_dispatch' && '1' || '0' }}
        run: |
          set -euo pipefail
          tags=()
          slim_tags=()
          if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
            tags+=("${IMAGE}:main")
            slim_tags+=("${IMAGE}:main-slim")
          fi
          if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
            version="${SOURCE_REF#refs/tags/v}"
            tags+=("${IMAGE}:${version}")
            slim_tags+=("${IMAGE}:${version}-slim")
            # Manual backfills should only republish the requested version tags.
            if [[ "${IS_MANUAL_BACKFILL}" != "1" && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
              tags+=("${IMAGE}:latest")
              slim_tags+=("${IMAGE}:slim")
            fi
          fi
          if [[ ${#tags[@]} -eq 0 ]]; then
            echo "::error::No manifest tags resolved for ref ${SOURCE_REF}"
            exit 1
          fi
          {
            echo "value<<EOF"
            printf "%s\n" "${tags[@]}"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"
          {
            echo "slim<<EOF"
            printf "%s\n" "${slim_tags[@]}"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"

      - name: Create and push default manifest
        shell: bash
        run: |
          set -euo pipefail
          mapfile -t tags <<< "${{ steps.tags.outputs.value }}"
          args=()
          for tag in "${tags[@]}"; do
            [ -z "$tag" ] && continue
            args+=("-t" "$tag")
          done
          docker buildx imagetools create "${args[@]}" \
            ${{ needs.build-amd64.outputs.digest }} \
            ${{ needs.build-arm64.outputs.digest }}

      - name: Create and push slim manifest
        shell: bash
        run: |
          set -euo pipefail
          mapfile -t tags <<< "${{ steps.tags.outputs.slim }}"
          args=()
          for tag in "${tags[@]}"; do
            [ -z "$tag" ] && continue
            args+=("-t" "$tag")
          done
          docker buildx imagetools create "${args[@]}" \
            ${{ needs.build-amd64.outputs.slim-digest }} \
            ${{ needs.build-arm64.outputs.slim-digest }}
install-smoke .github/workflows/install-smoke.yml
Triggers
push, pull_request, workflow_dispatch
Runs on
blacksmith-16vcpu-ubuntu-2404, blacksmith-16vcpu-ubuntu-2404
Jobs
preflight, install-smoke
Actions
docker/setup-buildx-action, useblacksmith/build-push-action, useblacksmith/build-push-action, useblacksmith/build-push-action, useblacksmith/build-push-action
Commands
  • set -euo pipefail if [ "${{ github.event_name }}" = "push" ]; then BASE="${{ github.event.before }}" else BASE="${{ github.event.pull_request.base.sha }}" fi node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
  • node scripts/ci-write-manifest-outputs.mjs --workflow install-smoke
  • docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
  • docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc ' which openclaw && openclaw --version && node -e " const Module = require(\"node:module\"); const matrixPackage = require(\"/app/extensions/matrix/package.json\"); const requireFromMatrix = Module.createRequire(\"/app/extensions/matrix/package.json\"); const runtimeDeps = Object.keys(matrixPackage.dependencies ?? {}); if (runtimeDeps.length === 0) { throw new Error( \"matrix package has no declared runtime dependencies; smoke cannot validate install mirroring\", ); } for (const dep of runtimeDeps) { requireFromMatrix.resolve(dep); } const { spawnSync } = require(\"node:child_process\"); const run = spawnSync(\"openclaw\", [\"plugins\", \"list\", \"--json\"], { encoding: \"utf8\" }); if (run.status !== 0) { process.stderr.write(run.stderr || run.stdout || \"plugins list failed\\n\"); process.exit(run.status ?? 1); } const parsed = JSON.parse(run.stdout); const matrix = (parsed.plugins || []).find((entry) => entry.id === \"matrix\"); if (!matrix) { throw new Error(\"matrix plugin missing from bundled plugin list\"); } const matrixDiag = (parsed.diagnostics || []).filter( (diag) => typeof diag.source === \"string\" && diag.source.includes(\"/extensions/matrix\") && typeof diag.message === \"string\" && diag.message.includes(\"extension entry escapes package directory\"), ); if (matrixDiag.length > 0) { throw new Error( \"unexpected matrix diagnostics: \" + matrixDiag.map((diag) => diag.message).join(\"; \"), ); } " '
  • bash scripts/test-install-sh-docker.sh
View raw YAML
name: Install Smoke

on:
  push:
    branches: [main]
  pull_request:
    types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
  workflow_dispatch:

concurrency:
  group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.run_id) }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
  preflight:
    if: github.event_name != 'pull_request' || !github.event.pull_request.draft
    runs-on: blacksmith-16vcpu-ubuntu-2404
    outputs:
      docs_only: ${{ steps.manifest.outputs.docs_only }}
      run_install_smoke: ${{ steps.manifest.outputs.run_install_smoke }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          fetch-depth: 1
          fetch-tags: false
          persist-credentials: false
          submodules: false

      - name: Ensure preflight base commit
        uses: ./.github/actions/ensure-base-commit
        with:
          base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
          fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}

      - name: Detect docs-only changes
        id: docs_scope
        uses: ./.github/actions/detect-docs-changes

      - name: Detect changed smoke scope
        id: changed_scope
        if: steps.docs_scope.outputs.docs_only != 'true'
        shell: bash
        run: |
          set -euo pipefail

          if [ "${{ github.event_name }}" = "push" ]; then
            BASE="${{ github.event.before }}"
          else
            BASE="${{ github.event.pull_request.base.sha }}"
          fi

          node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD

      - name: Setup Node environment
        if: steps.docs_scope.outputs.docs_only != 'true'
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          install-deps: "false"
          use-sticky-disk: "false"

      - name: Build install-smoke CI manifest
        id: manifest
        env:
          OPENCLAW_CI_DOCS_ONLY: ${{ steps.docs_scope.outputs.docs_only }}
          OPENCLAW_CI_DOCS_CHANGED: "false"
          OPENCLAW_CI_RUN_NODE: "false"
          OPENCLAW_CI_RUN_MACOS: "false"
          OPENCLAW_CI_RUN_ANDROID: "false"
          OPENCLAW_CI_RUN_WINDOWS: "false"
          OPENCLAW_CI_RUN_SKILLS_PYTHON: "false"
          OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: "false"
          OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: '{"include":[]}'
          OPENCLAW_CI_RUN_CHANGED_SMOKE: ${{ steps.changed_scope.outputs.run_changed_smoke || 'false' }}
        run: node scripts/ci-write-manifest-outputs.mjs --workflow install-smoke

  install-smoke:
    needs: [preflight]
    if: needs.preflight.outputs.run_install_smoke == 'true'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    env:
      DOCKER_BUILD_SUMMARY: "false"
      DOCKER_BUILD_RECORD_UPLOAD: "false"
    steps:
      - name: Checkout CLI
        uses: actions/checkout@v6

      - name: Set up Docker Builder
        uses: docker/setup-buildx-action@v4

      # Blacksmith can fall back to the local docker driver, which rejects gha
      # cache export/import. Keep smoke builds driver-agnostic.
      - name: Build root Dockerfile smoke image
        uses: useblacksmith/build-push-action@v2
        with:
          context: .
          file: ./Dockerfile
          build-args: |
            OPENCLAW_DOCKER_APT_UPGRADE=0
          tags: openclaw-dockerfile-smoke:local
          load: true
          push: false
          provenance: false

      - name: Run root Dockerfile CLI smoke
        run: |
          docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'

      # This smoke validates that the build-arg path preinstalls the matrix
      # runtime deps declared by the plugin and that matrix discovery stays
      # healthy in the final runtime image.
      - name: Build extension Dockerfile smoke image
        uses: useblacksmith/build-push-action@v2
        with:
          context: .
          file: ./Dockerfile
          build-args: |
            OPENCLAW_DOCKER_APT_UPGRADE=0
            OPENCLAW_EXTENSIONS=matrix
          tags: openclaw-ext-smoke:local
          load: true
          push: false
          provenance: false

      - name: Smoke test Dockerfile with matrix extension build arg
        run: |
          docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc '
            which openclaw &&
            openclaw --version &&
            node -e "
              const Module = require(\"node:module\");
              const matrixPackage = require(\"/app/extensions/matrix/package.json\");
              const requireFromMatrix = Module.createRequire(\"/app/extensions/matrix/package.json\");
              const runtimeDeps = Object.keys(matrixPackage.dependencies ?? {});
              if (runtimeDeps.length === 0) {
                throw new Error(
                  \"matrix package has no declared runtime dependencies; smoke cannot validate install mirroring\",
                );
              }
              for (const dep of runtimeDeps) {
                requireFromMatrix.resolve(dep);
              }
              const { spawnSync } = require(\"node:child_process\");
              const run = spawnSync(\"openclaw\", [\"plugins\", \"list\", \"--json\"], { encoding: \"utf8\" });
              if (run.status !== 0) {
                process.stderr.write(run.stderr || run.stdout || \"plugins list failed\\n\");
                process.exit(run.status ?? 1);
              }
              const parsed = JSON.parse(run.stdout);
              const matrix = (parsed.plugins || []).find((entry) => entry.id === \"matrix\");
              if (!matrix) {
                throw new Error(\"matrix plugin missing from bundled plugin list\");
              }
              const matrixDiag = (parsed.diagnostics || []).filter(
                (diag) =>
                  typeof diag.source === \"string\" &&
                  diag.source.includes(\"/extensions/matrix\") &&
                  typeof diag.message === \"string\" &&
                  diag.message.includes(\"extension entry escapes package directory\"),
              );
              if (matrixDiag.length > 0) {
                throw new Error(
                  \"unexpected matrix diagnostics: \" +
                    matrixDiag.map((diag) => diag.message).join(\"; \"),
                );
              }
            "
          '

      - name: Build installer smoke image
        uses: useblacksmith/build-push-action@v2
        with:
          context: ./scripts/docker
          file: ./scripts/docker/install-sh-smoke/Dockerfile
          tags: openclaw-install-smoke:local
          load: true
          push: false
          provenance: false

      - name: Build installer non-root image
        if: github.event_name != 'pull_request'
        uses: useblacksmith/build-push-action@v2
        with:
          context: ./scripts/docker
          file: ./scripts/docker/install-sh-nonroot/Dockerfile
          tags: openclaw-install-nonroot:local
          load: true
          push: false
          provenance: false

      - name: Run installer docker tests
        env:
          OPENCLAW_INSTALL_URL: https://openclaw.ai/install.sh
          OPENCLAW_INSTALL_CLI_URL: https://openclaw.ai/install-cli.sh
          OPENCLAW_NO_ONBOARD: "1"
          OPENCLAW_INSTALL_SMOKE_SKIP_CLI: "1"
          OPENCLAW_INSTALL_SMOKE_SKIP_IMAGE_BUILD: "1"
          OPENCLAW_INSTALL_NONROOT_SKIP_IMAGE_BUILD: ${{ github.event_name == 'pull_request' && '0' || '1' }}
          OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
          OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS: "1"
        run: bash scripts/test-install-sh-docker.sh
labeler perms .github/workflows/labeler.yml
Triggers
pull_request_target, issues, workflow_dispatch
Runs on
blacksmith-16vcpu-ubuntu-2404, blacksmith-16vcpu-ubuntu-2404, blacksmith-16vcpu-ubuntu-2404
Jobs
label, backfill-pr-labels, label-issues
Actions
actions/create-github-app-token, actions/create-github-app-token, actions/labeler, actions/create-github-app-token, actions/create-github-app-token, actions/create-github-app-token, actions/create-github-app-token
View raw YAML
name: Labeler

on:
  pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned triage workflow; no untrusted checkout or PR code execution
    types: [opened, synchronize, reopened, edited]
  issues:
    types: [opened, edited]
  workflow_dispatch:
    inputs:
      max_prs:
        description: "Maximum number of open PRs to process (0 = all)"
        required: false
        default: "200"
      per_page:
        description: "PRs per page (1-100)"
        required: false
        default: "50"

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }}
  cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}

permissions: {}

jobs:
  label:
    permissions:
      contents: read
      pull-requests: write
    runs-on: blacksmith-16vcpu-ubuntu-2404
    steps:
      - uses: actions/create-github-app-token@v2
        id: app-token
        continue-on-error: true
        with:
          app-id: "2729701"
          private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
      - uses: actions/create-github-app-token@v2
        id: app-token-fallback
        if: steps.app-token.outcome == 'failure'
        with:
          app-id: "2971289"
          private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
      - uses: actions/labeler@v6
        with:
          configuration-path: .github/labeler.yml
          repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
          sync-labels: true
      - name: Apply PR size label
        uses: actions/github-script@v8
        with:
          github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
          script: |
            const pullRequest = context.payload.pull_request;
            if (!pullRequest) {
              return;
            }

            const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
            const labelColor = "b76e79";

            for (const label of sizeLabels) {
              try {
                await github.rest.issues.getLabel({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  name: label,
                });
              } catch (error) {
                if (error?.status !== 404) {
                  throw error;
                }
                await github.rest.issues.createLabel({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  name: label,
                  color: labelColor,
                });
              }
            }

            const files = await github.paginate(github.rest.pulls.listFiles, {
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: pullRequest.number,
              per_page: 100,
            });

            const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
            const totalChangedLines = files.reduce((total, file) => {
              const path = file.filename ?? "";
              if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) {
                return total;
              }
              return total + (file.additions ?? 0) + (file.deletions ?? 0);
            }, 0);

            let targetSizeLabel = "size: XL";
            if (totalChangedLines < 50) {
              targetSizeLabel = "size: XS";
            } else if (totalChangedLines < 200) {
              targetSizeLabel = "size: S";
            } else if (totalChangedLines < 500) {
              targetSizeLabel = "size: M";
            } else if (totalChangedLines < 1000) {
              targetSizeLabel = "size: L";
            }

            const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: pullRequest.number,
              per_page: 100,
            });

            for (const label of currentLabels) {
              const name = label.name ?? "";
              if (!sizeLabels.includes(name)) {
                continue;
              }
              if (name === targetSizeLabel) {
                continue;
              }
              await github.rest.issues.removeLabel({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: pullRequest.number,
                name,
              });
            }

            await github.rest.issues.addLabels({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: pullRequest.number,
              labels: [targetSizeLabel],
            });
      - name: Apply maintainer or trusted-contributor label
        uses: actions/github-script@v8
        with:
          github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
          script: |
            const login = context.payload.pull_request?.user?.login;
            if (!login) {
              return;
            }

            const repo = `${context.repo.owner}/${context.repo.repo}`;
            // const trustedLabel = "trusted-contributor";
            // const experiencedLabel = "experienced-contributor";
            // const trustedThreshold = 4;
            // const experiencedThreshold = 10;

            let isMaintainer = false;
            try {
              const membership = await github.rest.teams.getMembershipForUserInOrg({
                org: context.repo.owner,
                team_slug: "maintainer",
                username: login,
              });
              isMaintainer = membership?.data?.state === "active";
            } catch (error) {
              if (error?.status !== 404) {
                throw error;
              }
            }

            if (isMaintainer) {
              await github.rest.issues.addLabels({
                ...context.repo,
                issue_number: context.payload.pull_request.number,
                labels: ["maintainer"],
              });
              return;
            }

            // trusted-contributor and experienced-contributor labels disabled.
            // const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`;
            // let mergedCount = 0;
            // try {
            //   const merged = await github.rest.search.issuesAndPullRequests({
            //     q: mergedQuery,
            //     per_page: 1,
            //   });
            //   mergedCount = merged?.data?.total_count ?? 0;
            // } catch (error) {
            //   if (error?.status !== 422) {
            //     throw error;
            //   }
            //   core.warning(`Skipping merged search for ${login}; treating as 0.`);
            // }
            //
            // if (mergedCount >= experiencedThreshold) {
            //   await github.rest.issues.addLabels({
            //     ...context.repo,
            //     issue_number: context.payload.pull_request.number,
            //     labels: [experiencedLabel],
            //   });
            //   return;
            // }
            //
            // if (mergedCount >= trustedThreshold) {
            //   await github.rest.issues.addLabels({
            //     ...context.repo,
            //     issue_number: context.payload.pull_request.number,
            //     labels: [trustedLabel],
            //   });
            // }
      - name: Apply beta-blocker title label
        uses: actions/github-script@v8
        with:
          github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
          script: |
            const pullRequest = context.payload.pull_request;
            if (!pullRequest) {
              return;
            }

            const labelName = "beta-blocker";
            const matchesBetaBlocker = /\bbeta blocker\b/i.test(pullRequest.title ?? "");

            try {
              await github.rest.issues.getLabel({
                owner: context.repo.owner,
                repo: context.repo.repo,
                name: labelName,
              });
            } catch (error) {
              if (error?.status !== 404) {
                throw error;
              }
              core.info(`Skipping ${labelName} labeling because the label does not exist in the repository.`);
              return;
            }

            const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: pullRequest.number,
              per_page: 100,
            });
            const hasLabel = currentLabels.some((label) => label.name === labelName);

            if (matchesBetaBlocker && !hasLabel) {
              await github.rest.issues.addLabels({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: pullRequest.number,
                labels: [labelName],
              });
              return;
            }

            if (!matchesBetaBlocker && hasLabel) {
              await github.rest.issues.removeLabel({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: pullRequest.number,
                name: labelName,
              });
            }
      - name: Apply too-many-prs label
        uses: actions/github-script@v8
        with:
          github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
          script: |
            const pullRequest = context.payload.pull_request;
            if (!pullRequest) {
              return;
            }

            const activePrLimitLabel = "r: too-many-prs";
            const activePrLimitOverrideLabel = "r: too-many-prs-override";
            const activePrLimit = 10;
            const labelColor = "B60205";
            const labelDescription = `Author has more than ${activePrLimit} active PRs in this repo`;
            const authorLogin = pullRequest.user?.login;
            if (!authorLogin) {
              return;
            }

            const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: pullRequest.number,
              per_page: 100,
            });

            const labelNames = new Set(
              currentLabels
                .map((label) => (typeof label === "string" ? label : label?.name))
                .filter((name) => typeof name === "string"),
            );

            if (labelNames.has(activePrLimitOverrideLabel)) {
              if (labelNames.has(activePrLimitLabel)) {
                try {
                  await github.rest.issues.removeLabel({
                    owner: context.repo.owner,
                    repo: context.repo.repo,
                    issue_number: pullRequest.number,
                    name: activePrLimitLabel,
                  });
                } catch (error) {
                  if (error?.status !== 404) {
                    throw error;
                  }
                }
              }
              return;
            }

            const ensureLabelExists = async () => {
              try {
                await github.rest.issues.getLabel({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  name: activePrLimitLabel,
                });
              } catch (error) {
                if (error?.status !== 404) {
                  throw error;
                }
                await github.rest.issues.createLabel({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  name: activePrLimitLabel,
                  color: labelColor,
                  description: labelDescription,
                });
              }
            };

            const isPrivilegedAuthor = async () => {
              if (pullRequest.author_association === "OWNER") {
                return true;
              }

              let isMaintainer = false;
              try {
                const membership = await github.rest.teams.getMembershipForUserInOrg({
                  org: context.repo.owner,
                  team_slug: "maintainer",
                  username: authorLogin,
                });
                isMaintainer = membership?.data?.state === "active";
              } catch (error) {
                if (error?.status !== 404) {
                  throw error;
                }
              }

              if (isMaintainer) {
                return true;
              }

              try {
                const permission = await github.rest.repos.getCollaboratorPermissionLevel({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  username: authorLogin,
                });
                const roleName = (permission?.data?.role_name ?? "").toLowerCase();
                return roleName === "admin" || roleName === "maintain";
              } catch (error) {
                if (error?.status !== 404) {
                  throw error;
                }
              }

              return false;
            };

            if (await isPrivilegedAuthor()) {
              if (labelNames.has(activePrLimitLabel)) {
                try {
                  await github.rest.issues.removeLabel({
                    owner: context.repo.owner,
                    repo: context.repo.repo,
                    issue_number: pullRequest.number,
                    name: activePrLimitLabel,
                  });
                } catch (error) {
                  if (error?.status !== 404) {
                    throw error;
                  }
                }
              }
              return;
            }

            let openPrCount = 0;
            try {
              const result = await github.rest.search.issuesAndPullRequests({
                q: `repo:${context.repo.owner}/${context.repo.repo} is:pr is:open author:${authorLogin}`,
                per_page: 1,
              });
              openPrCount = result?.data?.total_count ?? 0;
            } catch (error) {
              if (error?.status !== 422) {
                throw error;
              }
              core.warning(`Skipping open PR count for ${authorLogin}; treating as 0.`);
            }

            if (openPrCount > activePrLimit) {
              await ensureLabelExists();
              if (!labelNames.has(activePrLimitLabel)) {
                await github.rest.issues.addLabels({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: pullRequest.number,
                  labels: [activePrLimitLabel],
                });
              }
              return;
            }

            if (labelNames.has(activePrLimitLabel)) {
              try {
                await github.rest.issues.removeLabel({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: pullRequest.number,
                  name: activePrLimitLabel,
                });
              } catch (error) {
                if (error?.status !== 404) {
                  throw error;
                }
              }
            }

  backfill-pr-labels:
    if: github.event_name == 'workflow_dispatch'
    permissions:
      contents: read
      pull-requests: write
    runs-on: blacksmith-16vcpu-ubuntu-2404
    steps:
      - uses: actions/create-github-app-token@v2
        id: app-token
        continue-on-error: true
        with:
          app-id: "2729701"
          private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
      - uses: actions/create-github-app-token@v2
        id: app-token-fallback
        if: steps.app-token.outcome == 'failure'
        with:
          app-id: "2971289"
          private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
      - name: Backfill PR labels
        uses: actions/github-script@v8
        with:
          github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
          script: |
            const owner = context.repo.owner;
            const repo = context.repo.repo;
            const repoFull = `${owner}/${repo}`;
            const inputs = context.payload.inputs ?? {};
            const maxPrsInput = inputs.max_prs ?? "200";
            const perPageInput = inputs.per_page ?? "50";
            const parsedMaxPrs = Number.parseInt(maxPrsInput, 10);
            const parsedPerPage = Number.parseInt(perPageInput, 10);
            const maxPrs = Number.isFinite(parsedMaxPrs) ? parsedMaxPrs : 200;
            const perPage = Number.isFinite(parsedPerPage) ? Math.min(100, Math.max(1, parsedPerPage)) : 50;
            const processAll = maxPrs <= 0;
            const maxCount = processAll ? Number.POSITIVE_INFINITY : Math.max(1, maxPrs);

            const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
            const betaBlockerLabel = "beta-blocker";
            const labelColor = "b76e79";
            // const trustedLabel = "trusted-contributor";
            // const experiencedLabel = "experienced-contributor";
            // const trustedThreshold = 4;
            // const experiencedThreshold = 10;

            const contributorCache = new Map();

            async function ensureSizeLabels() {
              for (const label of sizeLabels) {
                try {
                  await github.rest.issues.getLabel({
                    owner,
                    repo,
                    name: label,
                  });
                } catch (error) {
                  if (error?.status !== 404) {
                    throw error;
                  }
                  await github.rest.issues.createLabel({
                    owner,
                    repo,
                    name: label,
                    color: labelColor,
                  });
                }
              }
            }

            async function hasBetaBlockerLabel() {
              try {
                await github.rest.issues.getLabel({
                  owner,
                  repo,
                  name: betaBlockerLabel,
                });
                return true;
              } catch (error) {
                if (error?.status !== 404) {
                  throw error;
                }
                return false;
              }
            }

            async function resolveContributorLabel(login) {
              if (contributorCache.has(login)) {
                return contributorCache.get(login);
              }

              let isMaintainer = false;
              try {
                const membership = await github.rest.teams.getMembershipForUserInOrg({
                  org: owner,
                  team_slug: "maintainer",
                  username: login,
                });
                isMaintainer = membership?.data?.state === "active";
              } catch (error) {
                if (error?.status !== 404) {
                  throw error;
                }
              }

              if (isMaintainer) {
                contributorCache.set(login, "maintainer");
                return "maintainer";
              }

              // trusted-contributor and experienced-contributor labels disabled.
              // const mergedQuery = `repo:${repoFull} is:pr is:merged author:${login}`;
              // let mergedCount = 0;
              // try {
              //   const merged = await github.rest.search.issuesAndPullRequests({
              //     q: mergedQuery,
              //     per_page: 1,
              //   });
              //   mergedCount = merged?.data?.total_count ?? 0;
              // } catch (error) {
              //   if (error?.status !== 422) {
              //     throw error;
              //   }
              //   core.warning(`Skipping merged search for ${login}; treating as 0.`);
              // }

              const label = null;
              // if (mergedCount >= experiencedThreshold) {
              //   label = experiencedLabel;
              // } else if (mergedCount >= trustedThreshold) {
              //   label = trustedLabel;
              // }

              contributorCache.set(login, label);
              return label;
            }

            async function applySizeLabel(pullRequest, currentLabels, labelNames) {
              const files = await github.paginate(github.rest.pulls.listFiles, {
                owner,
                repo,
                pull_number: pullRequest.number,
                per_page: 100,
              });

              const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
              const totalChangedLines = files.reduce((total, file) => {
                const path = file.filename ?? "";
                if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) {
                  return total;
                }
                return total + (file.additions ?? 0) + (file.deletions ?? 0);
              }, 0);

              let targetSizeLabel = "size: XL";
              if (totalChangedLines < 50) {
                targetSizeLabel = "size: XS";
              } else if (totalChangedLines < 200) {
                targetSizeLabel = "size: S";
              } else if (totalChangedLines < 500) {
                targetSizeLabel = "size: M";
              } else if (totalChangedLines < 1000) {
                targetSizeLabel = "size: L";
              }

              for (const label of currentLabels) {
                const name = label.name ?? "";
                if (!sizeLabels.includes(name)) {
                  continue;
                }
                if (name === targetSizeLabel) {
                  continue;
                }
                await github.rest.issues.removeLabel({
                  owner,
                  repo,
                  issue_number: pullRequest.number,
                  name,
                });
                labelNames.delete(name);
              }

              if (!labelNames.has(targetSizeLabel)) {
                await github.rest.issues.addLabels({
                  owner,
                  repo,
                  issue_number: pullRequest.number,
                  labels: [targetSizeLabel],
                });
                labelNames.add(targetSizeLabel);
              }
            }

            async function applyContributorLabel(pullRequest, labelNames) {
              const login = pullRequest.user?.login;
              if (!login) {
                return;
              }

              const label = await resolveContributorLabel(login);
              if (!label) {
                return;
              }

              if (labelNames.has(label)) {
                return;
              }

              await github.rest.issues.addLabels({
                owner,
                repo,
                issue_number: pullRequest.number,
                labels: [label],
              });
              labelNames.add(label);
            }

            async function applyBetaBlockerTitleLabel(pullRequest, labelNames) {
              const matchesBetaBlocker = /\bbeta blocker\b/i.test(pullRequest.title ?? "");

              if (matchesBetaBlocker) {
                if (!labelNames.has(betaBlockerLabel)) {
                  await github.rest.issues.addLabels({
                    owner,
                    repo,
                    issue_number: pullRequest.number,
                    labels: [betaBlockerLabel],
                  });
                  labelNames.add(betaBlockerLabel);
                }
                return;
              }

              if (!labelNames.has(betaBlockerLabel)) {
                return;
              }

              await github.rest.issues.removeLabel({
                owner,
                repo,
                issue_number: pullRequest.number,
                name: betaBlockerLabel,
              });
              labelNames.delete(betaBlockerLabel);
            }

            await ensureSizeLabels();
            const betaBlockerLabelExists = await hasBetaBlockerLabel();

            let page = 1;
            let processed = 0;

            while (processed < maxCount) {
              const remaining = maxCount - processed;
              const pageSize = processAll ? perPage : Math.min(perPage, remaining);
              const { data: pullRequests } = await github.rest.pulls.list({
                owner,
                repo,
                state: "open",
                per_page: pageSize,
                page,
              });

              if (pullRequests.length === 0) {
                break;
              }

              for (const pullRequest of pullRequests) {
                if (!processAll && processed >= maxCount) {
                  break;
                }

                const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
                  owner,
                  repo,
                  issue_number: pullRequest.number,
                  per_page: 100,
                });

                const labelNames = new Set(
                  currentLabels.map((label) => label.name).filter((name) => typeof name === "string"),
                );

                await applySizeLabel(pullRequest, currentLabels, labelNames);
                await applyContributorLabel(pullRequest, labelNames);
                if (betaBlockerLabelExists) {
                  await applyBetaBlockerTitleLabel(pullRequest, labelNames);
                }

                processed += 1;
              }

              if (pullRequests.length < pageSize) {
                break;
              }

              page += 1;
            }

            core.info(`Processed ${processed} pull requests.`);

  label-issues:
    permissions:
      issues: write
    runs-on: blacksmith-16vcpu-ubuntu-2404
    steps:
      - uses: actions/create-github-app-token@v2
        id: app-token
        continue-on-error: true
        with:
          app-id: "2729701"
          private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
      - uses: actions/create-github-app-token@v2
        id: app-token-fallback
        if: steps.app-token.outcome == 'failure'
        with:
          app-id: "2971289"
          private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
      - name: Apply maintainer or trusted-contributor label
        uses: actions/github-script@v8
        with:
          github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
          script: |
            const login = context.payload.issue?.user?.login;
            if (!login) {
              return;
            }

            const repo = `${context.repo.owner}/${context.repo.repo}`;
            // const trustedLabel = "trusted-contributor";
            // const experiencedLabel = "experienced-contributor";
            // const trustedThreshold = 4;
            // const experiencedThreshold = 10;

            let isMaintainer = false;
            try {
              const membership = await github.rest.teams.getMembershipForUserInOrg({
                org: context.repo.owner,
                team_slug: "maintainer",
                username: login,
              });
              isMaintainer = membership?.data?.state === "active";
            } catch (error) {
              if (error?.status !== 404) {
                throw error;
              }
            }

            if (isMaintainer) {
              await github.rest.issues.addLabels({
                ...context.repo,
                issue_number: context.payload.issue.number,
                labels: ["maintainer"],
              });
              return;
            }

            // trusted-contributor and experienced-contributor labels disabled.
            // const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`;
            // let mergedCount = 0;
            // try {
            //   const merged = await github.rest.search.issuesAndPullRequests({
            //     q: mergedQuery,
            //     per_page: 1,
            //   });
            //   mergedCount = merged?.data?.total_count ?? 0;
            // } catch (error) {
            //   if (error?.status !== 422) {
            //     throw error;
            //   }
            //   core.warning(`Skipping merged search for ${login}; treating as 0.`);
            // }
            //
            // if (mergedCount >= experiencedThreshold) {
            //   await github.rest.issues.addLabels({
            //     ...context.repo,
            //     issue_number: context.payload.issue.number,
            //     labels: [experiencedLabel],
            //   });
            //   return;
            // }
            //
            // if (mergedCount >= trustedThreshold) {
            //   await github.rest.issues.addLabels({
            //     ...context.repo,
            //     issue_number: context.payload.issue.number,
            //     labels: [trustedLabel],
            //   });
            // }
      - name: Apply beta-blocker title label
        uses: actions/github-script@v8
        with:
          github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
          script: |
            const issue = context.payload.issue;
            if (!issue || issue.pull_request) {
              return;
            }

            const labelName = "beta-blocker";
            const matchesBetaBlocker = /^beta blocker:/i.test(issue.title ?? "");

            try {
              await github.rest.issues.getLabel({
                owner: context.repo.owner,
                repo: context.repo.repo,
                name: labelName,
              });
            } catch (error) {
              if (error?.status !== 404) {
                throw error;
              }
              core.info(`Skipping ${labelName} labeling because the label does not exist in the repository.`);
              return;
            }

            const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: issue.number,
              per_page: 100,
            });
            const hasLabel = currentLabels.some((label) => label.name === labelName);

            if (matchesBetaBlocker && !hasLabel) {
              await github.rest.issues.addLabels({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: issue.number,
                labels: [labelName],
              });
              return;
            }

            if (!matchesBetaBlocker && hasLabel) {
              await github.rest.issues.removeLabel({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: issue.number,
                name: labelName,
              });
            }
macos-release .github/workflows/macos-release.yml
Triggers
workflow_dispatch
Runs on
ubuntu-latest
Jobs
validate_macos_release_request
Commands
  • set -euo pipefail if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then echo "Invalid release tag format: ${RELEASE_TAG}" exit 1 fi
  • gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
  • pnpm build
  • pnpm ui:build
  • set -euo pipefail RELEASE_SHA=$(git rev-parse HEAD) export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main pnpm release:openclaw:npm:check
  • { echo "## Public macOS validation only" echo echo "This workflow no longer builds, signs, notarizes, or uploads macOS assets." echo echo "Next step:" echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml\` with tag \`${RELEASE_TAG}\`." echo "- Use \`preflight_only=true\` there for the full private mac preflight." echo "- For the real publish path, the private run uploads the packaged \`.zip\`, \`.dmg\`, and \`.dSYM.zip\` files to the existing GitHub release in \`openclaw/openclaw\` automatically." echo "- For stable releases, also download \`macos-appcast-${RELEASE_TAG}\` from the successful private run and commit \`appcast.xml\` back to \`main\` in \`openclaw/openclaw\`." } >> "$GITHUB_STEP_SUMMARY"
View raw YAML
name: macOS Release

on:
  workflow_dispatch:
    inputs:
      tag:
        description: Existing release tag to validate for macOS release handoff (for example v2026.3.22 or v2026.3.22-beta.1)
        required: true
        type: string
      preflight_only:
        description: Retained for operator compatibility; this public workflow is validation-only
        required: true
        default: true
        type: boolean

concurrency:
  group: macos-release-${{ inputs.tag }}
  cancel-in-progress: false

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
  NODE_VERSION: "24.x"
  PNPM_VERSION: "10.23.0"

jobs:
  validate_macos_release_request:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Validate tag input format
        env:
          RELEASE_TAG: ${{ inputs.tag }}
        run: |
          set -euo pipefail
          if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
            echo "Invalid release tag format: ${RELEASE_TAG}"
            exit 1
          fi

      - name: Checkout selected tag
        uses: actions/checkout@v6
        with:
          ref: refs/tags/${{ inputs.tag }}
          fetch-depth: 0

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          node-version: ${{ env.NODE_VERSION }}
          pnpm-version: ${{ env.PNPM_VERSION }}
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Ensure matching GitHub release exists
        env:
          GH_TOKEN: ${{ github.token }}
          RELEASE_TAG: ${{ inputs.tag }}
        run: gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null

      - name: Build
        run: pnpm build

      - name: Build Control UI
        run: pnpm ui:build

      - name: Validate release tag and package metadata
        env:
          RELEASE_TAG: ${{ inputs.tag }}
          RELEASE_MAIN_REF: origin/main
        run: |
          set -euo pipefail
          RELEASE_SHA=$(git rev-parse HEAD)
          export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
          git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
          pnpm release:openclaw:npm:check

      - name: Summarize next step
        env:
          RELEASE_TAG: ${{ inputs.tag }}
        run: |
          {
            echo "## Public macOS validation only"
            echo
            echo "This workflow no longer builds, signs, notarizes, or uploads macOS assets."
            echo
            echo "Next step:"
            echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml\` with tag \`${RELEASE_TAG}\`."
            echo "- Use \`preflight_only=true\` there for the full private mac preflight."
            echo "- For the real publish path, the private run uploads the packaged \`.zip\`, \`.dmg\`, and \`.dSYM.zip\` files to the existing GitHub release in \`openclaw/openclaw\` automatically."
            echo "- For stable releases, also download \`macos-appcast-${RELEASE_TAG}\` from the successful private run and commit \`appcast.xml\` back to \`main\` in \`openclaw/openclaw\`."
          } >> "$GITHUB_STEP_SUMMARY"
openclaw-npm-release .github/workflows/openclaw-npm-release.yml
Triggers
workflow_dispatch
Runs on
ubuntu-latest, ubuntu-latest, ubuntu-latest
Jobs
preflight_openclaw_npm, validate_publish_dispatch_ref, publish_openclaw_npm
Commands
  • set -euo pipefail if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then echo "Invalid release tag format: ${RELEASE_TAG}" exit 1 fi
  • set -euo pipefail PACKAGE_VERSION=$(node -p "require('./package.json').version") if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then echo "openclaw@${PACKAGE_VERSION} is already published on npm; continuing because preflight_only=true." exit 0 fi echo "openclaw@${PACKAGE_VERSION} is already published on npm." exit 1 fi echo "Publishing openclaw@${PACKAGE_VERSION}"
  • pnpm check
  • pnpm build
  • pnpm ui:build
  • set -euo pipefail RELEASE_SHA=$(git rev-parse HEAD) export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF # Fetch the full main ref so merge-base ancestry checks keep working # for older tagged commits that are still contained in main. git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main pnpm release:openclaw:npm:check
  • pnpm release:check
  • set -euo pipefail if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then echo "Real publish runs must be dispatched from main. Use preflight_only=true for branch validation." exit 1 fi
View raw YAML
name: OpenClaw NPM Release

on:
  workflow_dispatch:
    inputs:
      tag:
        description: Release tag to publish (for example v2026.3.22, v2026.3.22-beta.1, or fallback v2026.3.22-1)
        required: true
        type: string
      preflight_only:
        description: Run validation/build only and skip the gated publish job
        required: true
        default: false
        type: boolean

concurrency:
  group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
  cancel-in-progress: false

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
  NODE_VERSION: "24.x"
  PNPM_VERSION: "10.23.0"

jobs:
  preflight_openclaw_npm:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Validate tag input format
        env:
          RELEASE_TAG: ${{ inputs.tag }}
        run: |
          set -euo pipefail
          if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
            echo "Invalid release tag format: ${RELEASE_TAG}"
            exit 1
          fi

      - name: Checkout
        uses: actions/checkout@v6
        with:
          ref: refs/tags/${{ inputs.tag }}
          fetch-depth: 0

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          node-version: ${{ env.NODE_VERSION }}
          pnpm-version: ${{ env.PNPM_VERSION }}
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Ensure version is not already published
        env:
          PREFLIGHT_ONLY: ${{ inputs.preflight_only }}
        run: |
          set -euo pipefail
          PACKAGE_VERSION=$(node -p "require('./package.json').version")

          if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
            if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then
              echo "openclaw@${PACKAGE_VERSION} is already published on npm; continuing because preflight_only=true."
              exit 0
            fi
            echo "openclaw@${PACKAGE_VERSION} is already published on npm."
            exit 1
          fi

          echo "Publishing openclaw@${PACKAGE_VERSION}"

      - name: Check
        run: pnpm check

      - name: Build
        run: pnpm build

      - name: Build Control UI
        run: pnpm ui:build

      - name: Validate release tag and package metadata
        env:
          RELEASE_TAG: ${{ inputs.tag }}
          RELEASE_MAIN_REF: origin/main
        run: |
          set -euo pipefail
          RELEASE_SHA=$(git rev-parse HEAD)
          export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
          # Fetch the full main ref so merge-base ancestry checks keep working
          # for older tagged commits that are still contained in main.
          git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
          pnpm release:openclaw:npm:check

      - name: Verify release contents
        run: pnpm release:check

  validate_publish_dispatch_ref:
    if: ${{ !inputs.preflight_only }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Require main workflow ref for publish
        env:
          WORKFLOW_REF: ${{ github.ref }}
        run: |
          set -euo pipefail
          if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
            echo "Real publish runs must be dispatched from main. Use preflight_only=true for branch validation."
            exit 1
          fi

  publish_openclaw_npm:
    # npm trusted publishing + provenance requires a GitHub-hosted runner.
    needs: [preflight_openclaw_npm, validate_publish_dispatch_ref]
    if: ${{ !inputs.preflight_only }}
    runs-on: ubuntu-latest
    environment: npm-release
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Validate tag input format
        env:
          RELEASE_TAG: ${{ inputs.tag }}
        run: |
          set -euo pipefail
          if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
            echo "Invalid release tag format: ${RELEASE_TAG}"
            exit 1
          fi

      - name: Checkout
        uses: actions/checkout@v6
        with:
          ref: refs/tags/${{ inputs.tag }}
          fetch-depth: 0

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          node-version: ${{ env.NODE_VERSION }}
          pnpm-version: ${{ env.PNPM_VERSION }}
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Ensure version is not already published
        run: |
          set -euo pipefail
          PACKAGE_VERSION=$(node -p "require('./package.json').version")

          if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
            echo "openclaw@${PACKAGE_VERSION} is already published on npm."
            exit 1
          fi

          echo "Publishing openclaw@${PACKAGE_VERSION}"

      - name: Build
        run: pnpm build

      - name: Build Control UI
        run: pnpm ui:build

      - name: Validate release tag and package metadata
        env:
          RELEASE_TAG: ${{ inputs.tag }}
          RELEASE_MAIN_REF: origin/main
        run: |
          set -euo pipefail
          RELEASE_SHA=$(git rev-parse HEAD)
          export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
          # Fetch the full main ref so merge-base ancestry checks keep working
          # for older tagged commits that are still contained in main.
          git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
          pnpm release:openclaw:npm:check

      - name: Publish
        run: bash scripts/openclaw-npm-publish.sh --publish
plugin-npm-release matrix .github/workflows/plugin-npm-release.yml
Triggers
push, workflow_dispatch
Runs on
ubuntu-latest, ubuntu-latest, ubuntu-latest
Jobs
preview_plugins_npm, preview_plugin_pack, publish_plugins_npm
Matrix
plugin→ ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }}
Commands
  • echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
  • set -euo pipefail git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main git merge-base --is-ancestor HEAD origin/main
  • set -euo pipefail if [[ -n "${PUBLISH_SCOPE}" ]]; then release_args=(--selection-mode "${PUBLISH_SCOPE}") if [[ -n "${RELEASE_PLUGINS}" ]]; then release_args+=(--plugins "${RELEASE_PLUGINS}") fi pnpm release:plugins:npm:check -- "${release_args[@]}" elif [[ -n "${BASE_REF}" ]]; then pnpm release:plugins:npm:check -- --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}" else pnpm release:plugins:npm:check fi
  • set -euo pipefail mkdir -p .local if [[ -n "${PUBLISH_SCOPE}" ]]; then plan_args=(--selection-mode "${PUBLISH_SCOPE}") if [[ -n "${RELEASE_PLUGINS}" ]]; then plan_args+=(--plugins "${RELEASE_PLUGINS}") fi node --import tsx scripts/plugin-npm-release-plan.ts "${plan_args[@]}" > .local/plugin-npm-release-plan.json elif [[ -n "${BASE_REF}" ]]; then node --import tsx scripts/plugin-npm-release-plan.ts --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}" > .local/plugin-npm-release-plan.json else node --import tsx scripts/plugin-npm-release-plan.ts > .local/plugin-npm-release-plan.json fi cat .local/plugin-npm-release-plan.json candidate_count="$(jq -r '.candidates | length' .local/plugin-npm-release-plan.json)" has_candidates="false" if [[ "${candidate_count}" != "0" ]]; then has_candidates="true" fi matrix_json="$(jq -c '.candidates' .local/plugin-npm-release-plan.json)" { echo "candidate_count=${candidate_count}" echo "has_candidates=${has_candidates}" echo "matrix=${matrix_json}" } >> "$GITHUB_OUTPUT" echo "Plugin release candidates:" jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-npm-release-plan.json echo "Already published / skipped:" jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-npm-release-plan.json
  • bash scripts/plugin-npm-publish.sh --dry-run "${{ matrix.plugin.packageDir }}"
  • npm pack --dry-run --json --ignore-scripts
  • set -euo pipefail if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on npm." exit 1 fi
  • bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}"
View raw YAML
name: Plugin NPM Release

on:
  push:
    branches:
      - main
    paths:
      - ".github/workflows/plugin-npm-release.yml"
      - "extensions/**"
      - "package.json"
      - "scripts/lib/plugin-npm-release.ts"
      - "scripts/plugin-npm-publish.sh"
      - "scripts/plugin-npm-release-check.ts"
      - "scripts/plugin-npm-release-plan.ts"
  workflow_dispatch:
    inputs:
      publish_scope:
        description: Publish the selected plugins or all publishable plugins from the ref
        required: true
        default: selected
        type: choice
        options:
          - selected
          - all-publishable
      ref:
        description: Commit SHA on main to publish from (copy from the preview run)
        required: true
        type: string
      plugins:
        description: Comma-separated plugin package names to publish when publish_scope=selected
        required: false
        type: string

concurrency:
  group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
  cancel-in-progress: false

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
  NODE_VERSION: "24.x"
  PNPM_VERSION: "10.23.0"

jobs:
  preview_plugins_npm:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    outputs:
      ref_sha: ${{ steps.ref.outputs.sha }}
      has_candidates: ${{ steps.plan.outputs.has_candidates }}
      candidate_count: ${{ steps.plan.outputs.candidate_count }}
      matrix: ${{ steps.plan.outputs.matrix }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
          fetch-depth: 0

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          node-version: ${{ env.NODE_VERSION }}
          pnpm-version: ${{ env.PNPM_VERSION }}
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Resolve checked-out ref
        id: ref
        run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

      - name: Validate ref is on main
        run: |
          set -euo pipefail
          git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
          git merge-base --is-ancestor HEAD origin/main

      - name: Validate publishable plugin metadata
        env:
          PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
          RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
          BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }}
          HEAD_REF: ${{ steps.ref.outputs.sha }}
        run: |
          set -euo pipefail
          if [[ -n "${PUBLISH_SCOPE}" ]]; then
            release_args=(--selection-mode "${PUBLISH_SCOPE}")
            if [[ -n "${RELEASE_PLUGINS}" ]]; then
              release_args+=(--plugins "${RELEASE_PLUGINS}")
            fi
            pnpm release:plugins:npm:check -- "${release_args[@]}"
          elif [[ -n "${BASE_REF}" ]]; then
            pnpm release:plugins:npm:check -- --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}"
          else
            pnpm release:plugins:npm:check
          fi

      - name: Resolve plugin release plan
        id: plan
        env:
          PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
          RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
          BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }}
          HEAD_REF: ${{ steps.ref.outputs.sha }}
        run: |
          set -euo pipefail
          mkdir -p .local
          if [[ -n "${PUBLISH_SCOPE}" ]]; then
            plan_args=(--selection-mode "${PUBLISH_SCOPE}")
            if [[ -n "${RELEASE_PLUGINS}" ]]; then
              plan_args+=(--plugins "${RELEASE_PLUGINS}")
            fi
            node --import tsx scripts/plugin-npm-release-plan.ts "${plan_args[@]}" > .local/plugin-npm-release-plan.json
          elif [[ -n "${BASE_REF}" ]]; then
            node --import tsx scripts/plugin-npm-release-plan.ts --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}" > .local/plugin-npm-release-plan.json
          else
            node --import tsx scripts/plugin-npm-release-plan.ts > .local/plugin-npm-release-plan.json
          fi

          cat .local/plugin-npm-release-plan.json

          candidate_count="$(jq -r '.candidates | length' .local/plugin-npm-release-plan.json)"
          has_candidates="false"
          if [[ "${candidate_count}" != "0" ]]; then
            has_candidates="true"
          fi
          matrix_json="$(jq -c '.candidates' .local/plugin-npm-release-plan.json)"

          {
            echo "candidate_count=${candidate_count}"
            echo "has_candidates=${has_candidates}"
            echo "matrix=${matrix_json}"
          } >> "$GITHUB_OUTPUT"

          echo "Plugin release candidates:"
          jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-npm-release-plan.json

          echo "Already published / skipped:"
          jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-npm-release-plan.json

  preview_plugin_pack:
    needs: preview_plugins_npm
    if: needs.preview_plugins_npm.outputs.has_candidates == 'true'
    runs-on: ubuntu-latest
    permissions:
      contents: read
    strategy:
      fail-fast: false
      matrix:
        plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          ref: ${{ needs.preview_plugins_npm.outputs.ref_sha }}
          fetch-depth: 1

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          node-version: ${{ env.NODE_VERSION }}
          pnpm-version: ${{ env.PNPM_VERSION }}
          install-bun: "false"
          use-sticky-disk: "false"
          install-deps: "false"

      - name: Preview publish command
        run: bash scripts/plugin-npm-publish.sh --dry-run "${{ matrix.plugin.packageDir }}"

      - name: Preview npm pack contents
        working-directory: ${{ matrix.plugin.packageDir }}
        run: npm pack --dry-run --json --ignore-scripts

  publish_plugins_npm:
    needs: [preview_plugins_npm, preview_plugin_pack]
    if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
    runs-on: ubuntu-latest
    environment: npm-release
    permissions:
      contents: read
      id-token: write
    strategy:
      fail-fast: false
      matrix:
        plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.matrix) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          ref: ${{ needs.preview_plugins_npm.outputs.ref_sha }}
          fetch-depth: 1

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          node-version: ${{ env.NODE_VERSION }}
          pnpm-version: ${{ env.PNPM_VERSION }}
          install-bun: "false"
          use-sticky-disk: "false"
          install-deps: "false"

      - name: Ensure version is not already published
        env:
          PACKAGE_NAME: ${{ matrix.plugin.packageName }}
          PACKAGE_VERSION: ${{ matrix.plugin.version }}
        run: |
          set -euo pipefail
          if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
            echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on npm."
            exit 1
          fi

      - name: Publish
        run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}"
sandbox-common-smoke .github/workflows/sandbox-common-smoke.yml
Triggers
push, pull_request
Runs on
blacksmith-16vcpu-ubuntu-2404
Jobs
sandbox-common-smoke
Actions
docker/setup-buildx-action
Commands
  • set -euo pipefail docker build -t openclaw-sandbox-smoke-base:bookworm-slim - <<'EOF' FROM debian:bookworm-slim RUN useradd --create-home --shell /bin/bash sandbox USER sandbox WORKDIR /home/sandbox EOF
  • set -euo pipefail BASE_IMAGE="openclaw-sandbox-smoke-base:bookworm-slim" \ TARGET_IMAGE="openclaw-sandbox-common-smoke:bookworm-slim" \ PACKAGES="ca-certificates" \ INSTALL_PNPM=0 \ INSTALL_BUN=0 \ INSTALL_BREW=0 \ FINAL_USER=sandbox \ scripts/sandbox-common-setup.sh u="$(docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc 'id -un')" test "$u" = "sandbox"
View raw YAML
name: Sandbox Common Smoke

on:
  push:
    branches: [main]
    paths:
      - Dockerfile.sandbox
      - Dockerfile.sandbox-common
      - scripts/sandbox-common-setup.sh
  pull_request:
    types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
    paths:
      - Dockerfile.sandbox
      - Dockerfile.sandbox-common
      - scripts/sandbox-common-setup.sh

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
  sandbox-common-smoke:
    if: github.event_name != 'pull_request' || !github.event.pull_request.draft
    runs-on: blacksmith-16vcpu-ubuntu-2404
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          submodules: false

      - name: Set up Docker Builder
        uses: docker/setup-buildx-action@v4

      - name: Build minimal sandbox base (USER sandbox)
        shell: bash
        run: |
          set -euo pipefail

          docker build -t openclaw-sandbox-smoke-base:bookworm-slim - <<'EOF'
          FROM debian:bookworm-slim
          RUN useradd --create-home --shell /bin/bash sandbox
          USER sandbox
          WORKDIR /home/sandbox
          EOF

      - name: Build sandbox-common image (root for installs, sandbox at runtime)
        shell: bash
        run: |
          set -euo pipefail

          BASE_IMAGE="openclaw-sandbox-smoke-base:bookworm-slim" \
            TARGET_IMAGE="openclaw-sandbox-common-smoke:bookworm-slim" \
            PACKAGES="ca-certificates" \
            INSTALL_PNPM=0 \
            INSTALL_BUN=0 \
            INSTALL_BREW=0 \
            FINAL_USER=sandbox \
            scripts/sandbox-common-setup.sh

          u="$(docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc 'id -un')"
          test "$u" = "sandbox"
stale perms .github/workflows/stale.yml
Triggers
schedule, workflow_dispatch
Runs on
blacksmith-16vcpu-ubuntu-2404, blacksmith-16vcpu-ubuntu-2404
Jobs
stale, lock-closed-issues
Actions
actions/create-github-app-token, actions/create-github-app-token, actions/stale, actions/stale, actions/create-github-app-token
View raw YAML
name: Stale

on:
  schedule:
    - cron: "17 3 * * *"
  workflow_dispatch:

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

permissions: {}

jobs:
  stale:
    permissions:
      issues: write
      pull-requests: write
    runs-on: blacksmith-16vcpu-ubuntu-2404
    steps:
      - uses: actions/create-github-app-token@v2
        id: app-token
        continue-on-error: true
        with:
          app-id: "2729701"
          private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
      - uses: actions/create-github-app-token@v2
        id: app-token-fallback
        continue-on-error: true
        with:
          app-id: "2971289"
          private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
      - name: Mark stale issues and pull requests (primary)
        id: stale-primary
        continue-on-error: true
        uses: actions/stale@v10
        with:
          repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
          days-before-issue-stale: 7
          days-before-issue-close: 5
          days-before-pr-stale: 5
          days-before-pr-close: 3
          stale-issue-label: stale
          stale-pr-label: stale
          exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
          exempt-pr-labels: maintainer,no-stale,bad-barnacle
          operations-per-run: 2000
          ascending: true
          exempt-all-assignees: true
          remove-stale-when-updated: true
          stale-issue-message: |
            This issue has been automatically marked as stale due to inactivity.
            Please add updates or it will be closed.
          stale-pr-message: |
            This pull request has been automatically marked as stale due to inactivity.
            Please add updates or it will be closed.
          close-issue-message: |
            Closing due to inactivity.
            If this is still an issue, please retry on the latest OpenClaw release and share updated details.
            If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps.
          close-issue-reason: not_planned
          close-pr-message: |
            Closing due to inactivity.
            If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
            That channel is the escape hatch for high-quality PRs that get auto-closed.
      - name: Check stale state cache
        id: stale-state
        if: always()
        uses: actions/github-script@v8
        with:
          github-token: ${{ steps.app-token-fallback.outputs.token || steps.app-token.outputs.token }}
          script: |
            const cacheKey = "_state";
            const { owner, repo } = context.repo;

            try {
              const { data } = await github.rest.actions.getActionsCacheList({
                owner,
                repo,
                key: cacheKey,
              });
              const caches = data.actions_caches ?? [];
              const hasState = caches.some(cache => cache.key === cacheKey);
              core.setOutput("has_state", hasState ? "true" : "false");
            } catch (error) {
              const message = error instanceof Error ? error.message : String(error);
              core.warning(`Failed to check stale state cache: ${message}`);
              core.setOutput("has_state", "false");
            }
      - name: Mark stale issues and pull requests (fallback)
        if: (steps.stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != ''
        uses: actions/stale@v10
        with:
          repo-token: ${{ steps.app-token-fallback.outputs.token }}
          days-before-issue-stale: 7
          days-before-issue-close: 5
          days-before-pr-stale: 5
          days-before-pr-close: 3
          stale-issue-label: stale
          stale-pr-label: stale
          exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
          exempt-pr-labels: maintainer,no-stale,bad-barnacle
          operations-per-run: 2000
          ascending: true
          exempt-all-assignees: true
          remove-stale-when-updated: true
          stale-issue-message: |
            This issue has been automatically marked as stale due to inactivity.
            Please add updates or it will be closed.
          stale-pr-message: |
            This pull request has been automatically marked as stale due to inactivity.
            Please add updates or it will be closed.
          close-issue-message: |
            Closing due to inactivity.
            If this is still an issue, please retry on the latest OpenClaw release and share updated details.
            If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps.
          close-issue-reason: not_planned
          close-pr-message: |
            Closing due to inactivity.
            If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
            That channel is the escape hatch for high-quality PRs that get auto-closed.

  lock-closed-issues:
    permissions:
      issues: write
    runs-on: blacksmith-16vcpu-ubuntu-2404
    steps:
      - uses: actions/create-github-app-token@v2
        id: app-token
        with:
          app-id: "2729701"
          private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
      - name: Lock closed issues after 48h of no comments
        uses: actions/github-script@v8
        with:
          github-token: ${{ steps.app-token.outputs.token }}
          script: |
            const lockAfterHours = 48;
            const lockAfterMs = lockAfterHours * 60 * 60 * 1000;
            const perPage = 100;
            const cutoffMs = Date.now() - lockAfterMs;
            const { owner, repo } = context.repo;

            let locked = 0;
            let inspected = 0;

            let page = 1;
            while (true) {
              const { data: issues } = await github.rest.issues.listForRepo({
                owner,
                repo,
                state: "closed",
                sort: "updated",
                direction: "desc",
                per_page: perPage,
                page,
              });

              if (issues.length === 0) {
                break;
              }

              for (const issue of issues) {
                if (issue.pull_request) {
                  continue;
                }
                if (issue.locked) {
                  continue;
                }
                if (!issue.closed_at) {
                  continue;
                }

                inspected += 1;
                const closedAtMs = Date.parse(issue.closed_at);
                if (!Number.isFinite(closedAtMs)) {
                  continue;
                }
                if (closedAtMs > cutoffMs) {
                  continue;
                }

                let lastCommentMs = 0;
                if (issue.comments > 0) {
                  const { data: comments } = await github.rest.issues.listComments({
                    owner,
                    repo,
                    issue_number: issue.number,
                    per_page: 1,
                    page: 1,
                    sort: "created",
                    direction: "desc",
                  });

                  if (comments.length > 0) {
                    lastCommentMs = Date.parse(comments[0].created_at);
                  }
                }

                const lastActivityMs = Math.max(closedAtMs, lastCommentMs || 0);
                if (lastActivityMs > cutoffMs) {
                  continue;
                }

                await github.rest.issues.lock({
                  owner,
                  repo,
                  issue_number: issue.number,
                  lock_reason: "resolved",
                });

                locked += 1;
              }

              page += 1;
            }

            core.info(`Inspected ${inspected} closed issues; locked ${locked}.`);
workflow-sanity .github/workflows/workflow-sanity.yml
Triggers
pull_request, push, workflow_dispatch
Runs on
blacksmith-16vcpu-ubuntu-2404, blacksmith-16vcpu-ubuntu-2404, blacksmith-16vcpu-ubuntu-2404
Jobs
no-tabs, actionlint, generated-doc-baselines
Commands
  • python - <<'PY' from __future__ import annotations import pathlib import sys root = pathlib.Path(".github/workflows") bad: list[str] = [] for path in sorted(root.rglob("*.yml")): if b"\t" in path.read_bytes(): bad.append(str(path)) for path in sorted(root.rglob("*.yaml")): if b"\t" in path.read_bytes(): bad.append(str(path)) if bad: print("Tabs found in workflow file(s):") for path in bad: print(f"- {path}") sys.exit(1) PY
  • set -euo pipefail ACTIONLINT_VERSION="1.7.11" archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" base_url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}" curl -sSfL -o "${archive}" "${base_url}/${archive}" curl -sSfL -o checksums.txt "${base_url}/actionlint_${ACTIONLINT_VERSION}_checksums.txt" grep " ${archive}\$" checksums.txt | sha256sum -c - tar -xzf "${archive}" actionlint sudo install -m 0755 actionlint /usr/local/bin/actionlint
  • actionlint
  • python3 scripts/check-composite-action-input-interpolation.py
  • node scripts/check-no-conflict-markers.mjs
  • pnpm config:docs:check
  • pnpm plugin-sdk:api:check
View raw YAML
name: Workflow Sanity

on:
  pull_request:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
  no-tabs:
    if: github.event_name != 'workflow_dispatch'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Fail on tabs in workflow files
        run: |
          python - <<'PY'
          from __future__ import annotations

          import pathlib
          import sys

          root = pathlib.Path(".github/workflows")
          bad: list[str] = []
          for path in sorted(root.rglob("*.yml")):
            if b"\t" in path.read_bytes():
              bad.append(str(path))

          for path in sorted(root.rglob("*.yaml")):
            if b"\t" in path.read_bytes():
              bad.append(str(path))

          if bad:
            print("Tabs found in workflow file(s):")
            for path in bad:
              print(f"- {path}")
            sys.exit(1)
          PY

  actionlint:
    if: github.event_name != 'workflow_dispatch'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Install actionlint
        shell: bash
        run: |
          set -euo pipefail
          ACTIONLINT_VERSION="1.7.11"
          archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
          base_url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}"
          curl -sSfL -o "${archive}" "${base_url}/${archive}"
          curl -sSfL -o checksums.txt "${base_url}/actionlint_${ACTIONLINT_VERSION}_checksums.txt"
          grep " ${archive}\$" checksums.txt | sha256sum -c -
          tar -xzf "${archive}" actionlint
          sudo install -m 0755 actionlint /usr/local/bin/actionlint

      - name: Lint workflows
        run: actionlint

      - name: Disallow direct inputs interpolation in composite run blocks
        run: python3 scripts/check-composite-action-input-interpolation.py

      - name: Disallow tracked merge conflict markers
        run: node scripts/check-no-conflict-markers.mjs

  generated-doc-baselines:
    if: github.event_name == 'workflow_dispatch'
    runs-on: blacksmith-16vcpu-ubuntu-2404
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Setup Node environment
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"
          use-sticky-disk: "false"

      - name: Check config docs drift statefile
        run: pnpm config:docs:check

      - name: Check plugin SDK API baseline drift
        run: pnpm plugin-sdk:api:check