mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
The resolve step echoes the publish command with shlex.quote, which is shell quoting rather than output escaping: it wraps a value holding a line break in single quotes and leaves the break itself intact. A caller-supplied changelog, categories or topics value carrying a newline therefore opened a second line in the step log, and the runner parses each stdout line, so that second line reached it as a workflow command. Escape the parts that are not printable in the echo. The re-runnable .sh file keeps plain shell quoting, because there the quoting is what makes the script correct.
741 lines
31 KiB
YAML
741 lines
31 KiB
YAML
name: Package Publish
|
|
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
source:
|
|
description: Package source to publish. Usually owner/repo, owner/repo@ref, or a GitHub URL.
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
ref:
|
|
description: Optional ref to append to the source when source is not already pinned.
|
|
required: false
|
|
type: string
|
|
dry_run:
|
|
description: Preview only. When true, no publish mutation is performed.
|
|
required: false
|
|
type: boolean
|
|
default: true
|
|
json:
|
|
description: Emit structured JSON output.
|
|
required: false
|
|
type: boolean
|
|
default: true
|
|
wait_for_publication:
|
|
description: Wait for security checks and definitive publication on real publishes.
|
|
required: false
|
|
type: boolean
|
|
default: true
|
|
publication_timeout_minutes:
|
|
description: Maximum minutes to wait for definitive publication.
|
|
required: false
|
|
type: number
|
|
default: 30
|
|
registry:
|
|
description: ClawHub registry URL.
|
|
required: false
|
|
type: string
|
|
default: https://clawhub.ai
|
|
site:
|
|
description: ClawHub site URL.
|
|
required: false
|
|
type: string
|
|
default: https://clawhub.ai
|
|
owner:
|
|
description: Optional owner handle override for org/shared publishing.
|
|
required: false
|
|
type: string
|
|
family:
|
|
description: Optional package family override for legacy package rows.
|
|
required: false
|
|
type: string
|
|
version:
|
|
description: Optional package version override.
|
|
required: false
|
|
type: string
|
|
tags:
|
|
description: Optional comma-separated tags override.
|
|
required: false
|
|
type: string
|
|
default: latest
|
|
changelog:
|
|
description: Optional release changelog shown on ClawHub.
|
|
required: false
|
|
type: string
|
|
categories:
|
|
description: Optional comma-separated plugin category slugs.
|
|
required: false
|
|
type: string
|
|
clear_categories:
|
|
description: Clear existing plugin categories. Cannot be combined with categories.
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
topics:
|
|
description: Optional comma-separated catalog topics.
|
|
required: false
|
|
type: string
|
|
clear_topics:
|
|
description: Clear existing catalog topics. Cannot be combined with topics.
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
source_repo:
|
|
description: Optional source repo override for local-folder publishes.
|
|
required: false
|
|
type: string
|
|
source_commit:
|
|
description: Optional source commit override for local-folder publishes.
|
|
required: false
|
|
type: string
|
|
source_ref:
|
|
description: Optional source ref override for local-folder publishes.
|
|
required: false
|
|
type: string
|
|
source_path:
|
|
description: Optional source path inside the repository for monorepo package publishes.
|
|
required: false
|
|
type: string
|
|
package_artifact_name:
|
|
description: Optional Actions artifact name containing a prebuilt ClawPack .tgz to publish.
|
|
required: false
|
|
type: string
|
|
package_artifact_path:
|
|
description: Optional path to the .tgz inside package_artifact_name. Defaults to the only .tgz in the artifact.
|
|
required: false
|
|
type: string
|
|
inspector_artifact_name:
|
|
description: Artifact name for plugin inspector reports. Set a unique value when calling this workflow from a matrix.
|
|
required: false
|
|
type: string
|
|
default: plugin-inspector-report
|
|
publish_json_artifact_name:
|
|
description: Artifact name for the package publish JSON output. Set a unique value when calling this workflow from a matrix.
|
|
required: false
|
|
type: string
|
|
default: clawhub-package-publish-json
|
|
secrets:
|
|
clawhub_token:
|
|
required: false
|
|
outputs:
|
|
publish_json:
|
|
description: Structured JSON output from clawhub package publish.
|
|
value: ${{ jobs.publish.outputs.publish_json }}
|
|
release_id:
|
|
description: Published release id when dry_run is false.
|
|
value: ${{ jobs.publish.outputs.release_id }}
|
|
|
|
env:
|
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
|
|
|
permissions: {}
|
|
|
|
jobs:
|
|
publish:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 75
|
|
permissions:
|
|
actions: read
|
|
contents: read
|
|
id-token: write
|
|
outputs:
|
|
publish_json: ${{ steps.capture.outputs.publish_json }}
|
|
release_id: ${{ steps.capture.outputs.release_id }}
|
|
steps:
|
|
- uses: actions/checkout@v7.0.1
|
|
with:
|
|
ref: ${{ github.sha }}
|
|
|
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
|
with:
|
|
bun-version: 1.3.10
|
|
|
|
- name: Resolve ClawHub workflow source
|
|
id: clawhub_source
|
|
run: |
|
|
python3 - <<'PY'
|
|
import base64
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from urllib.request import Request, urlopen
|
|
|
|
request_token = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "").strip()
|
|
request_url = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_URL", "").strip()
|
|
if not request_token or not request_url:
|
|
raise SystemExit("GitHub OIDC token request env vars are missing; id-token: write is required.")
|
|
|
|
audience = "clawhub-workflow-source"
|
|
joiner = "&" if "?" in request_url else "?"
|
|
token_url = f"{request_url}{joiner}audience={audience}"
|
|
request = Request(
|
|
token_url,
|
|
headers={"Authorization": f"Bearer {request_token}"},
|
|
)
|
|
with urlopen(request) as response:
|
|
payload = json.load(response)
|
|
|
|
token = str(payload.get("value", "")).strip()
|
|
if not token:
|
|
raise SystemExit("GitHub OIDC token response did not include a token value.")
|
|
|
|
try:
|
|
encoded_payload = token.split(".")[1]
|
|
except IndexError as exc:
|
|
raise SystemExit("GitHub OIDC token was not a valid JWT.") from exc
|
|
padding = "=" * (-len(encoded_payload) % 4)
|
|
claims = json.loads(
|
|
base64.urlsafe_b64decode(encoded_payload + padding).decode("utf-8")
|
|
)
|
|
|
|
workflow_ref = str(claims.get("job_workflow_ref", "")).strip()
|
|
workflow_sha = str(claims.get("job_workflow_sha", "")).strip()
|
|
repo, marker, _ = workflow_ref.partition("/.github/workflows/")
|
|
if not marker or not repo or not workflow_sha:
|
|
raise SystemExit(
|
|
"Unable to resolve reusable workflow source from GitHub OIDC claims: "
|
|
f"job_workflow_ref={workflow_ref!r} job_workflow_sha={workflow_sha!r}"
|
|
)
|
|
|
|
output_path = Path(os.environ["GITHUB_OUTPUT"])
|
|
with output_path.open("a", encoding="utf-8") as fh:
|
|
fh.write(f"repository={repo}\n")
|
|
fh.write(f"ref={workflow_sha}\n")
|
|
PY
|
|
|
|
- uses: actions/checkout@v7.0.1
|
|
with:
|
|
repository: ${{ steps.clawhub_source.outputs.repository }}
|
|
ref: ${{ steps.clawhub_source.outputs.ref }}
|
|
path: clawhub-source
|
|
|
|
- name: Install ClawHub CLI dependencies
|
|
working-directory: clawhub-source
|
|
run: bun install --frozen-lockfile
|
|
|
|
- name: Validate publish mode inputs
|
|
env:
|
|
DRY_RUN: ${{ inputs.dry_run }}
|
|
JSON_MODE: ${{ inputs.json }}
|
|
WAIT_FOR_PUBLICATION: ${{ inputs.wait_for_publication }}
|
|
PUBLICATION_TIMEOUT_MINUTES: ${{ inputs.publication_timeout_minutes }}
|
|
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
|
|
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
|
run: |
|
|
if [[ "$JSON_MODE" != "true" ]]; then
|
|
echo "::warning::This reusable workflow always emits JSON output; forcing --json for downstream parsing."
|
|
fi
|
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
exit 0
|
|
fi
|
|
if [[ "$WAIT_FOR_PUBLICATION" == "true" ]] && { ! [[ "$PUBLICATION_TIMEOUT_MINUTES" =~ ^[1-9][0-9]*$ ]] || (( PUBLICATION_TIMEOUT_MINUTES > 40 )); }; then
|
|
echo "::error::publication_timeout_minutes must be an integer from 1 through 40."
|
|
exit 1
|
|
fi
|
|
if [[ -n "$CLAWHUB_TOKEN" ]]; then
|
|
exit 0
|
|
fi
|
|
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" && -n "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]]; then
|
|
echo "No ClawHub token provided; publish will rely on GitHub OIDC trusted publishing."
|
|
exit 0
|
|
fi
|
|
echo "::error::Real publishes need secrets.clawhub_token, or GitHub OIDC on workflow_dispatch runs (permissions.id-token=write)."
|
|
exit 1
|
|
|
|
- name: Write ClawHub config
|
|
env:
|
|
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
|
|
CLAWHUB_REGISTRY: ${{ inputs.registry }}
|
|
run: |
|
|
if [[ -z "$CLAWHUB_TOKEN" ]]; then
|
|
echo "No ClawHub token provided, skipping config file creation."
|
|
exit 0
|
|
fi
|
|
|
|
python3 - <<'PY'
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
path = Path(os.environ["RUNNER_TEMP"]) / "clawhub-config.json"
|
|
path.write_text(
|
|
json.dumps(
|
|
{
|
|
"registry": os.environ["CLAWHUB_REGISTRY"],
|
|
"token": os.environ["CLAWHUB_TOKEN"],
|
|
},
|
|
indent=2,
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
print(path)
|
|
PY
|
|
echo "CLAWHUB_CONFIG_PATH=$RUNNER_TEMP/clawhub-config.json" >> "$GITHUB_ENV"
|
|
|
|
- name: Download prebuilt package artifact
|
|
if: inputs.package_artifact_name != ''
|
|
uses: actions/download-artifact@v8
|
|
with:
|
|
name: ${{ inputs.package_artifact_name }}
|
|
path: ${{ runner.temp }}/prebuilt-package-artifact
|
|
|
|
- name: Resolve prebuilt package artifact
|
|
id: resolve_artifact
|
|
env:
|
|
INPUT_PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name }}
|
|
INPUT_PACKAGE_ARTIFACT_PATH: ${{ inputs.package_artifact_path }}
|
|
run: |
|
|
python3 - <<'PY'
|
|
import os
|
|
from pathlib import Path
|
|
|
|
artifact_name = os.environ["INPUT_PACKAGE_ARTIFACT_NAME"].strip()
|
|
output_path = Path(os.environ["GITHUB_OUTPUT"])
|
|
if not artifact_name:
|
|
with output_path.open("a", encoding="utf-8") as fh:
|
|
fh.write("package_artifact_path=\n")
|
|
raise SystemExit(0)
|
|
|
|
artifact_root = Path(os.environ["RUNNER_TEMP"]) / "prebuilt-package-artifact"
|
|
requested_path = os.environ["INPUT_PACKAGE_ARTIFACT_PATH"].strip()
|
|
if requested_path:
|
|
candidate = (artifact_root / requested_path).resolve()
|
|
if artifact_root.resolve() not in candidate.parents and candidate != artifact_root.resolve():
|
|
raise SystemExit(f"Prebuilt artifact path escapes downloaded artifact: {requested_path}")
|
|
if not candidate.is_file():
|
|
raise SystemExit(f"Prebuilt package artifact path not found: {requested_path}")
|
|
else:
|
|
candidates = sorted(path for path in artifact_root.rglob("*.tgz") if path.is_file())
|
|
if not candidates:
|
|
raise SystemExit(f"Prebuilt package artifact {artifact_name!r} did not contain a .tgz file.")
|
|
if len(candidates) > 1:
|
|
joined = ", ".join(str(path.relative_to(artifact_root)) for path in candidates)
|
|
raise SystemExit(
|
|
"Prebuilt package artifact contains multiple .tgz files; set package_artifact_path. "
|
|
f"Found: {joined}"
|
|
)
|
|
candidate = candidates[0]
|
|
|
|
with output_path.open("a", encoding="utf-8") as fh:
|
|
fh.write(f"package_artifact_path={candidate}\n")
|
|
PY
|
|
|
|
- name: Resolve publish command
|
|
id: resolve_publish
|
|
env:
|
|
INPUT_SOURCE: ${{ inputs.source }}
|
|
INPUT_REF: ${{ inputs.ref }}
|
|
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
|
INPUT_WAIT_FOR_PUBLICATION: ${{ inputs.wait_for_publication }}
|
|
INPUT_PUBLICATION_TIMEOUT_MINUTES: ${{ inputs.publication_timeout_minutes }}
|
|
INPUT_OWNER: ${{ inputs.owner }}
|
|
INPUT_FAMILY: ${{ inputs.family }}
|
|
INPUT_VERSION: ${{ inputs.version }}
|
|
INPUT_TAGS: ${{ inputs.tags }}
|
|
INPUT_CHANGELOG: ${{ inputs.changelog }}
|
|
INPUT_CATEGORIES: ${{ inputs.categories }}
|
|
INPUT_CLEAR_CATEGORIES: ${{ inputs.clear_categories }}
|
|
INPUT_TOPICS: ${{ inputs.topics }}
|
|
INPUT_CLEAR_TOPICS: ${{ inputs.clear_topics }}
|
|
INPUT_SOURCE_REPO: ${{ inputs.source_repo }}
|
|
INPUT_SOURCE_COMMIT: ${{ inputs.source_commit }}
|
|
INPUT_SOURCE_REF: ${{ inputs.source_ref }}
|
|
INPUT_SOURCE_PATH: ${{ inputs.source_path }}
|
|
PREBUILT_PACKAGE_ARTIFACT_PATH: ${{ steps.resolve_artifact.outputs.package_artifact_path }}
|
|
INPUT_SITE: ${{ inputs.site }}
|
|
INPUT_REGISTRY: ${{ inputs.registry }}
|
|
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
GITHUB_REF: ${{ github.ref }}
|
|
GITHUB_SHA: ${{ github.sha }}
|
|
run: |
|
|
python3 - <<'PY'
|
|
import json
|
|
import os
|
|
import shlex
|
|
import uuid
|
|
from pathlib import Path
|
|
from urllib.error import HTTPError
|
|
from urllib.parse import quote, urlparse
|
|
from urllib.request import Request, urlopen
|
|
|
|
def quote_for_log(part):
|
|
# shlex.quote is shell quoting, not output escaping: it wraps a value holding a
|
|
# line break in single quotes and leaves the break itself intact. One newline in
|
|
# a caller's metadata would then open a second log line, which the runner reads
|
|
# as a ::workflow-command. json.dumps escapes every control character, so one
|
|
# publish stays one line however the caller fills changelog, categories or topics.
|
|
quoted = shlex.quote(part)
|
|
return quoted if quoted.isprintable() else json.dumps(part)
|
|
|
|
def split_ref_path(value):
|
|
if not value:
|
|
return "", ""
|
|
if ":" not in value:
|
|
return value, ""
|
|
ref, path = value.split(":", 1)
|
|
return ref, path.strip("/")
|
|
|
|
def github_commit_exists(repo, ref):
|
|
token = os.environ.get("GITHUB_TOKEN", "").strip()
|
|
headers = {
|
|
"Accept": "application/vnd.github+json",
|
|
"User-Agent": "clawhub-package-publish",
|
|
}
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
request = Request(
|
|
f"https://api.github.com/repos/{repo}/commits/{quote(ref, safe='')}",
|
|
headers=headers,
|
|
)
|
|
try:
|
|
with urlopen(request, timeout=10) as response:
|
|
return 200 <= response.status < 300
|
|
except HTTPError as error:
|
|
if error.code in (404, 422):
|
|
return False
|
|
raise
|
|
|
|
def resolve_github_url_ref_and_path(repo, kind, segments):
|
|
min_path_segments = 1 if kind == "blob" else 0
|
|
max_ref_segments = len(segments) - min_path_segments
|
|
for ref_segment_count in range(max_ref_segments, 0, -1):
|
|
ref = "/".join(segments[:ref_segment_count])
|
|
path = "/".join(segments[ref_segment_count:]).strip("/")
|
|
if kind == "blob" and not path:
|
|
continue
|
|
if not github_commit_exists(repo, ref):
|
|
continue
|
|
if kind == "blob":
|
|
path = "/".join(path.split("/")[:-1]).strip("/")
|
|
return ref, path
|
|
raise SystemExit(f"GitHub ref not found in source URL for {repo}")
|
|
|
|
def parse_github_source(value):
|
|
raw = value.strip()
|
|
if raw.startswith("github:"):
|
|
raw = raw[len("github:"):]
|
|
if raw.startswith("https://") or raw.startswith("http://"):
|
|
parsed = urlparse(raw)
|
|
if parsed.netloc.lower() != "github.com":
|
|
return None
|
|
parts = [part for part in parsed.path.strip("/").split("/") if part]
|
|
if len(parts) < 2:
|
|
return None
|
|
repo_name = parts[1][:-4] if parts[1].endswith(".git") else parts[1]
|
|
repo = f"{parts[0]}/{repo_name}"
|
|
if len(parts) >= 4 and parts[2] in {"tree", "blob"}:
|
|
ref, path = resolve_github_url_ref_and_path(repo, parts[2], parts[3:])
|
|
return {"repo": repo, "ref": ref, "path": path}
|
|
return {"repo": repo, "ref": "", "path": ""}
|
|
|
|
source_part, at, ref_part = raw.partition("@")
|
|
repo_parts = source_part.split("/")
|
|
if len(repo_parts) != 2 or not repo_parts[0] or not repo_parts[1]:
|
|
return None
|
|
repo_name = repo_parts[1][:-4] if repo_parts[1].endswith(".git") else repo_parts[1]
|
|
ref, path = split_ref_path(ref_part if at else "")
|
|
return {"repo": f"{repo_parts[0]}/{repo_name}", "ref": ref, "path": path}
|
|
|
|
source = os.environ["INPUT_SOURCE"].strip()
|
|
if not source:
|
|
source = os.environ["GITHUB_REPOSITORY"]
|
|
source_is_current_repo = source == os.environ["GITHUB_REPOSITORY"]
|
|
ref = os.environ["INPUT_REF"].strip()
|
|
if not ref and source_is_current_repo:
|
|
ref = os.environ["GITHUB_SHA"].strip()
|
|
is_local_source = source.startswith(".") or source.startswith("/") or Path(source).exists()
|
|
if ref and "@" not in source and not source.startswith("http") and not is_local_source:
|
|
source = f"{source}@{ref}"
|
|
source_path = os.environ["INPUT_SOURCE_PATH"].strip()
|
|
prebuilt_artifact_path = os.environ["PREBUILT_PACKAGE_ARTIFACT_PATH"].strip()
|
|
inspect_checkout_repository = ""
|
|
inspect_checkout_ref = ""
|
|
inspect_local_root = str(Path(os.environ["GITHUB_WORKSPACE"]).resolve())
|
|
inspect_subdir = source_path
|
|
if prebuilt_artifact_path:
|
|
inspect_local_root = str((Path(os.environ["RUNNER_TEMP"]) / "prebuilt-package-inspect").resolve())
|
|
inspect_subdir = ""
|
|
elif is_local_source:
|
|
inspect_local_root = str(Path(source).resolve())
|
|
else:
|
|
github_source = parse_github_source(source)
|
|
source_ref_differs_from_checkout = (
|
|
bool(github_source and github_source["ref"])
|
|
and github_source["ref"] != os.environ["GITHUB_SHA"]
|
|
)
|
|
if github_source and (
|
|
github_source["repo"] != os.environ["GITHUB_REPOSITORY"]
|
|
or source_ref_differs_from_checkout
|
|
):
|
|
inspect_checkout_repository = github_source["repo"]
|
|
inspect_checkout_ref = github_source["ref"]
|
|
inspect_local_root = str((Path(os.environ["GITHUB_WORKSPACE"]) / "clawhub-publish-source").resolve())
|
|
inspect_subdir = source_path or github_source["path"]
|
|
elif github_source:
|
|
inspect_subdir = source_path or github_source["path"]
|
|
|
|
cli_entry = (
|
|
Path(os.environ["GITHUB_WORKSPACE"])
|
|
/ "clawhub-source"
|
|
/ "packages"
|
|
/ "clawhub"
|
|
/ "src"
|
|
/ "cli.ts"
|
|
)
|
|
if not cli_entry.exists():
|
|
raise SystemExit(f"Missing ClawHub CLI entrypoint at {cli_entry}")
|
|
|
|
cmd_source = prebuilt_artifact_path or source
|
|
cmd = [
|
|
"bun",
|
|
str(cli_entry),
|
|
"package",
|
|
"publish",
|
|
cmd_source,
|
|
"--site",
|
|
os.environ["INPUT_SITE"],
|
|
"--registry",
|
|
os.environ["INPUT_REGISTRY"],
|
|
]
|
|
|
|
if os.environ["INPUT_DRY_RUN"] == "true":
|
|
cmd.append("--dry-run")
|
|
elif os.environ["INPUT_WAIT_FOR_PUBLICATION"] == "true":
|
|
timeout_minutes = int(os.environ["INPUT_PUBLICATION_TIMEOUT_MINUTES"])
|
|
cmd += ["--wait", "--wait-timeout", str(timeout_minutes * 60)]
|
|
cmd.append("--json")
|
|
|
|
owner = os.environ["INPUT_OWNER"].strip()
|
|
family = os.environ["INPUT_FAMILY"].strip()
|
|
version = os.environ["INPUT_VERSION"].strip()
|
|
tags = os.environ["INPUT_TAGS"].strip()
|
|
changelog = os.environ["INPUT_CHANGELOG"].strip()
|
|
categories = os.environ["INPUT_CATEGORIES"].strip()
|
|
clear_categories = os.environ["INPUT_CLEAR_CATEGORIES"].strip().lower() == "true"
|
|
topics = os.environ["INPUT_TOPICS"].strip()
|
|
clear_topics = os.environ["INPUT_CLEAR_TOPICS"].strip().lower() == "true"
|
|
if categories and clear_categories:
|
|
raise SystemExit("categories and clear_categories cannot be combined")
|
|
if topics and clear_topics:
|
|
raise SystemExit("topics and clear_topics cannot be combined")
|
|
if owner:
|
|
cmd += ["--owner", owner]
|
|
if family:
|
|
valid_families = {"code-plugin", "bundle-plugin"}
|
|
if family not in valid_families:
|
|
raise SystemExit(
|
|
f"Invalid package family override {family!r}; expected one of {sorted(valid_families)}."
|
|
)
|
|
cmd += ["--family", family]
|
|
if version:
|
|
cmd += ["--version", version]
|
|
if tags:
|
|
cmd += ["--tags", tags]
|
|
if changelog:
|
|
cmd += ["--changelog", changelog]
|
|
if categories:
|
|
cmd += ["--categories", categories]
|
|
elif clear_categories:
|
|
cmd += ["--categories", ""]
|
|
if topics:
|
|
cmd += ["--topics", topics]
|
|
elif clear_topics:
|
|
cmd += ["--topics", ""]
|
|
source_repo = os.environ["INPUT_SOURCE_REPO"].strip()
|
|
source_commit = os.environ["INPUT_SOURCE_COMMIT"].strip()
|
|
source_ref = os.environ["INPUT_SOURCE_REF"].strip()
|
|
if prebuilt_artifact_path:
|
|
if not source_repo and not source_commit:
|
|
source_repo = os.environ["GITHUB_REPOSITORY"].strip()
|
|
source_commit = os.environ["GITHUB_SHA"].strip()
|
|
elif not source_repo or not source_commit:
|
|
raise SystemExit(
|
|
"Prebuilt artifact mode requires source_repo and source_commit together when overriding source attribution."
|
|
)
|
|
if not source_ref:
|
|
source_ref = os.environ["GITHUB_REF"].strip()
|
|
if source_repo:
|
|
cmd += ["--source-repo", source_repo]
|
|
if source_commit:
|
|
cmd += ["--source-commit", source_commit]
|
|
if source_ref:
|
|
cmd += ["--source-ref", source_ref]
|
|
elif source_is_current_repo:
|
|
github_ref = os.environ["GITHUB_REF"].strip()
|
|
if github_ref:
|
|
cmd += ["--source-ref", github_ref]
|
|
if source_path:
|
|
cmd += ["--source-path", source_path]
|
|
if os.environ["INPUT_DRY_RUN"] != "true" and os.environ["CLAWHUB_TOKEN"].strip():
|
|
cmd += [
|
|
"--manual-override-reason",
|
|
f"GitHub Actions {os.environ['GITHUB_EVENT_NAME'].strip()} publish via CLAWHUB_TOKEN",
|
|
]
|
|
|
|
path = Path(os.environ["RUNNER_TEMP"]) / "clawhub-package-publish-command.sh"
|
|
shell_line = " ".join(shlex.quote(part) for part in cmd)
|
|
path.write_text("#!/usr/bin/env bash\nset -euo pipefail\n" + shell_line + "\n", encoding="utf-8")
|
|
path.chmod(0o755)
|
|
# Log-only: the file above is what a maintainer re-runs, so it keeps plain shell
|
|
# quoting. This echo does not, because the runner parses each stdout line.
|
|
print(" ".join(quote_for_log(part) for part in cmd))
|
|
|
|
def write_output(fh, name, value):
|
|
delimiter = f"ghadelimiter_{uuid.uuid4().hex}"
|
|
while delimiter in value:
|
|
delimiter = f"ghadelimiter_{uuid.uuid4().hex}"
|
|
fh.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n")
|
|
|
|
output_path = Path(os.environ["GITHUB_OUTPUT"])
|
|
with output_path.open("a", encoding="utf-8") as fh:
|
|
write_output(fh, "inspect_checkout_repository", inspect_checkout_repository)
|
|
write_output(fh, "inspect_checkout_ref", inspect_checkout_ref)
|
|
write_output(fh, "inspect_local_root", inspect_local_root)
|
|
write_output(fh, "inspect_subdir", inspect_subdir)
|
|
PY
|
|
|
|
- name: Extract prebuilt package artifact for plugin validation
|
|
if: steps.resolve_artifact.outputs.package_artifact_path != ''
|
|
env:
|
|
PREBUILT_PACKAGE_ARTIFACT_PATH: ${{ steps.resolve_artifact.outputs.package_artifact_path }}
|
|
INSPECT_LOCAL_ROOT: ${{ steps.resolve_publish.outputs.inspect_local_root }}
|
|
run: |
|
|
set -euo pipefail
|
|
rm -rf -- "$INSPECT_LOCAL_ROOT"
|
|
mkdir -p -- "$INSPECT_LOCAL_ROOT"
|
|
python3 - <<'PY'
|
|
import copy
|
|
import os
|
|
import tarfile
|
|
from pathlib import Path
|
|
from pathlib import PurePosixPath
|
|
|
|
archive_path = Path(os.environ["PREBUILT_PACKAGE_ARTIFACT_PATH"])
|
|
destination = Path(os.environ["INSPECT_LOCAL_ROOT"]).resolve()
|
|
safe_members = []
|
|
|
|
with tarfile.open(archive_path, mode="r:gz") as archive:
|
|
for member in archive.getmembers():
|
|
raw_parts = member.name.split("/")
|
|
if (
|
|
not member.name
|
|
or member.name.startswith("/")
|
|
or "\x00" in member.name
|
|
or any(part == ".." for part in raw_parts)
|
|
):
|
|
raise SystemExit(f"Unsafe prebuilt artifact member path: {member.name!r}")
|
|
if member.issym() or member.islnk():
|
|
raise SystemExit(f"Links are not allowed in prebuilt artifacts: {member.name!r}")
|
|
if not member.isdir() and not member.isreg():
|
|
raise SystemExit(f"Unsupported prebuilt artifact member: {member.name!r}")
|
|
|
|
stripped_parts = [part for part in raw_parts[1:] if part not in ("", ".")]
|
|
if not stripped_parts:
|
|
continue
|
|
stripped = PurePosixPath(*stripped_parts)
|
|
target = (destination.joinpath(*stripped.parts)).resolve()
|
|
try:
|
|
target.relative_to(destination)
|
|
except ValueError as exc:
|
|
raise SystemExit(
|
|
f"Prebuilt artifact member escapes extraction root: {member.name!r}"
|
|
) from exc
|
|
|
|
safe_member = copy.copy(member)
|
|
safe_member.name = str(stripped)
|
|
safe_member.linkname = ""
|
|
safe_members.append(safe_member)
|
|
|
|
archive.extractall(destination, members=safe_members)
|
|
PY
|
|
|
|
- name: Checkout publish source for plugin inspector
|
|
if: steps.resolve_publish.outputs.inspect_checkout_repository != ''
|
|
uses: actions/checkout@v7.0.1
|
|
with:
|
|
repository: ${{ steps.resolve_publish.outputs.inspect_checkout_repository }}
|
|
ref: ${{ steps.resolve_publish.outputs.inspect_checkout_ref }}
|
|
path: clawhub-publish-source
|
|
|
|
- name: Run plugin validation
|
|
env:
|
|
INSPECT_LOCAL_ROOT: ${{ steps.resolve_publish.outputs.inspect_local_root }}
|
|
INSPECT_SUBDIR: ${{ steps.resolve_publish.outputs.inspect_subdir }}
|
|
run: |
|
|
set -euo pipefail
|
|
inspect_root="$(python3 - <<'PY'
|
|
import os
|
|
from pathlib import Path
|
|
|
|
root = Path(os.environ["INSPECT_LOCAL_ROOT"]).resolve()
|
|
subdir = os.environ["INSPECT_SUBDIR"].strip()
|
|
inspect_root = (root / subdir).resolve() if subdir else root
|
|
if inspect_root != root and root not in inspect_root.parents:
|
|
raise SystemExit(f"Inspector source path escapes publish source: {subdir}")
|
|
print(inspect_root)
|
|
PY
|
|
)"
|
|
if [ ! -f "$inspect_root/package.json" ] && [ ! -f "$inspect_root/openclaw.plugin.json" ]; then
|
|
echo "::warning::Plugin Inspector skipped because $inspect_root is not a plugin root."
|
|
exit 0
|
|
fi
|
|
bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" package validate "$inspect_root" --out "$RUNNER_TEMP/plugin-inspector"
|
|
|
|
- name: Upload plugin inspector reports
|
|
if: always()
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: ${{ inputs.inspector_artifact_name }}
|
|
path: ${{ runner.temp }}/plugin-inspector
|
|
if-no-files-found: ignore
|
|
|
|
- name: Run package publish
|
|
run: |
|
|
set -euo pipefail
|
|
"$RUNNER_TEMP/clawhub-package-publish-command.sh" | tee "$RUNNER_TEMP/package-publish.json"
|
|
|
|
- name: Capture workflow outputs
|
|
id: capture
|
|
env:
|
|
DRY_RUN: ${{ inputs.dry_run }}
|
|
WAIT_FOR_PUBLICATION: ${{ inputs.wait_for_publication }}
|
|
run: |
|
|
python3 - <<'PY'
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
output_path = Path(os.environ["RUNNER_TEMP"]) / "package-publish.json"
|
|
raw = output_path.read_text(encoding="utf-8").strip()
|
|
parsed = json.loads(raw)
|
|
if (
|
|
os.environ["DRY_RUN"] != "true"
|
|
and os.environ["WAIT_FOR_PUBLICATION"] == "true"
|
|
and parsed.get("publicationStatus") != "published"
|
|
):
|
|
raise SystemExit(
|
|
"ClawHub package publish did not reach definitive publication: "
|
|
f"{parsed.get('publicationStatus', 'unknown')}"
|
|
)
|
|
|
|
github_output = Path(os.environ["GITHUB_OUTPUT"])
|
|
with github_output.open("a", encoding="utf-8") as fh:
|
|
fh.write("publish_json<<__CLAWHUB_JSON__\n")
|
|
fh.write(json.dumps(parsed, indent=2))
|
|
fh.write("\n__CLAWHUB_JSON__\n")
|
|
release_id = str(parsed.get("releaseId", "") or "")
|
|
fh.write(f"release_id={release_id}\n")
|
|
PY
|
|
|
|
- name: Upload publish JSON artifact
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: ${{ inputs.publish_json_artifact_name }}
|
|
path: ${{ runner.temp }}/package-publish.json
|
|
if-no-files-found: error
|