mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 04:41:14 +00:00
fix(release): complete protected publish tooling
This commit is contained in:
20
.github/workflows/openclaw-npm-release.yml
vendored
20
.github/workflows/openclaw-npm-release.yml
vendored
@@ -554,6 +554,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate npm release request
|
||||
@@ -569,18 +570,33 @@ jobs:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tideclaw_alpha_publish=false
|
||||
extended_stable_publish=false
|
||||
sha_pinned_release_publish=false
|
||||
if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha" && "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
tideclaw_alpha_publish=true
|
||||
fi
|
||||
if [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" && "${WORKFLOW_REF}" == refs/heads/extended-stable/* ]]; then
|
||||
extended_stable_publish=true
|
||||
fi
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ "${tideclaw_alpha_publish}" != "true" ]] && [[ "${extended_stable_publish}" != "true" ]]; then
|
||||
echo "Real publish runs must be dispatched from main, release/YYYY.M.PATCH, the exact validated extended-stable branch, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation."
|
||||
if [[ "${WORKFLOW_REF}" =~ ^refs/tags/release-publish/([a-f0-9]{12})-[1-9][0-9]*$ ]]; then
|
||||
workflow_sha_prefix="${BASH_REMATCH[1]}"
|
||||
[[ "${WORKFLOW_SHA}" =~ ^[a-f0-9]{40}$ && "${WORKFLOW_SHA:0:12}" == "${workflow_sha_prefix}" ]] || {
|
||||
echo "SHA-pinned release-publish tag does not match the OpenClaw npm workflow SHA." >&2
|
||||
exit 1
|
||||
}
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
git merge-base --is-ancestor "${WORKFLOW_SHA}" origin/main || {
|
||||
echo "SHA-pinned OpenClaw npm workflow revision is not reachable from current main." >&2
|
||||
exit 1
|
||||
}
|
||||
sha_pinned_release_publish=true
|
||||
fi
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ "${tideclaw_alpha_publish}" != "true" ]] && [[ "${extended_stable_publish}" != "true" ]] && [[ "${sha_pinned_release_publish}" != "true" ]]; then
|
||||
echo "Real publish runs must be dispatched from main, release/YYYY.M.PATCH, a protected SHA-pinned release-publish tag, the exact validated extended-stable branch, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
19
.github/workflows/openclaw-release-publish.yml
vendored
19
.github/workflows/openclaw-release-publish.yml
vendored
@@ -1034,14 +1034,17 @@ jobs:
|
||||
if [[ "$state" != "$last_state" ]]; then
|
||||
echo "${workflow} still ${status} (updated ${updated_at}): ${url}"
|
||||
print_pending_deployments "${workflow}" "${run_id}"
|
||||
approval_status=0
|
||||
approve_pending_deployments "${workflow}" "${run_id}" "${expected_sha}" ||
|
||||
approval_status=$?
|
||||
if (( approval_status > 1 )); then
|
||||
return 1
|
||||
fi
|
||||
last_state="$state"
|
||||
fi
|
||||
# The deployment gate can appear after the run first reports
|
||||
# waiting without changing updatedAt. Retry every poll so that
|
||||
# propagation lag cannot strand an approved release.
|
||||
approval_status=0
|
||||
approve_pending_deployments "${workflow}" "${run_id}" "${expected_sha}" ||
|
||||
approval_status=$?
|
||||
if (( approval_status > 1 )); then
|
||||
return 1
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
|
||||
@@ -1838,7 +1841,9 @@ jobs:
|
||||
verify_args+=(--npm-telegram-run "${NPM_TELEGRAM_RUN_ID}")
|
||||
fi
|
||||
|
||||
node --import tsx \
|
||||
OPENCLAW_NPM_EXPECTED_WORKFLOW_REF="${GITHUB_REF}" \
|
||||
OPENCLAW_NPM_EXPECTED_WORKFLOW_SHA="${PARENT_WORKFLOW_SHA}" \
|
||||
node --import tsx \
|
||||
"${GITHUB_WORKSPACE}/.release-harness/scripts/release-verify-beta.ts" \
|
||||
"${verify_args[@]}"
|
||||
|
||||
|
||||
15
.github/workflows/plugin-npm-release.yml
vendored
15
.github/workflows/plugin-npm-release.yml
vendored
@@ -1085,13 +1085,22 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
if [[ "$publish_route" == "npm-token-bootstrap" ]]; then
|
||||
[[ "$WORKFLOW_REF" == "refs/heads/main" ]] || {
|
||||
echo "npm token bootstrap requires the trusted main plugin release workflow." >&2
|
||||
sha_pinned_release_publish=false
|
||||
if [[ "$WORKFLOW_REF" =~ ^refs/tags/release-publish/([a-f0-9]{12})-[1-9][0-9]*$ ]]; then
|
||||
workflow_sha_prefix="${BASH_REMATCH[1]}"
|
||||
[[ "$WORKFLOW_SHA" =~ ^[a-f0-9]{40}$ && "${WORKFLOW_SHA:0:12}" == "$workflow_sha_prefix" ]] || {
|
||||
echo "npm token bootstrap release-publish tag does not match the workflow SHA." >&2
|
||||
exit 1
|
||||
}
|
||||
sha_pinned_release_publish=true
|
||||
fi
|
||||
[[ "$WORKFLOW_REF" == "refs/heads/main" || "$sha_pinned_release_publish" == "true" ]] || {
|
||||
echo "npm token bootstrap requires trusted main tooling or a protected SHA-pinned release-publish tag." >&2
|
||||
exit 1
|
||||
}
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
git merge-base --is-ancestor "$WORKFLOW_SHA" origin/main || {
|
||||
echo "npm token bootstrap workflow revision is not reachable from main." >&2
|
||||
echo "npm token bootstrap workflow revision is not reachable from current main." >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
@@ -201,6 +201,12 @@ type NpmProvenanceStatement = {
|
||||
repository?: string;
|
||||
};
|
||||
};
|
||||
resolvedDependencies?: Array<{
|
||||
digest?: {
|
||||
gitCommit?: string;
|
||||
};
|
||||
uri?: string;
|
||||
}>;
|
||||
};
|
||||
runDetails?: {
|
||||
builder?: {
|
||||
@@ -277,6 +283,10 @@ export function verifyNpmRegistrySignatures(params: {
|
||||
function resolveNpmProvenanceVerificationPolicy(
|
||||
statement: NpmProvenanceStatement,
|
||||
version: string,
|
||||
expectedWorkflow?: {
|
||||
ref?: string;
|
||||
sha?: string;
|
||||
},
|
||||
): NpmProvenanceVerificationPolicy {
|
||||
const parsedVersion = parseReleaseVersion(version);
|
||||
if (parsedVersion === null) {
|
||||
@@ -285,9 +295,37 @@ function resolveNpmProvenanceVerificationPolicy(
|
||||
const workflow = statement.predicate?.buildDefinition?.externalParameters?.workflow;
|
||||
const workflowRef = workflow?.ref;
|
||||
const expectedReleaseRef = `refs/heads/release/${parsedVersion.baseVersion}`;
|
||||
const protectedReleasePublishMatch =
|
||||
/^refs\/tags\/release-publish\/([a-f0-9]{12})-[1-9][0-9]*$/u.exec(workflowRef ?? "");
|
||||
let protectedReleasePublishTrusted = false;
|
||||
if (protectedReleasePublishMatch) {
|
||||
const expectedRef = expectedWorkflow?.ref;
|
||||
const expectedSha = expectedWorkflow?.sha;
|
||||
if (
|
||||
expectedRef !== workflowRef ||
|
||||
!/^[a-f0-9]{40}$/u.test(expectedSha ?? "") ||
|
||||
expectedSha?.slice(0, 12) !== protectedReleasePublishMatch[1]
|
||||
) {
|
||||
throw new Error(
|
||||
"npm provenance SHA-pinned release-publish ref does not match the approved workflow ref and SHA.",
|
||||
);
|
||||
}
|
||||
const expectedDependencyUri = `git+${NPM_PROVENANCE_REPOSITORY}@${workflowRef}`;
|
||||
protectedReleasePublishTrusted =
|
||||
statement.predicate?.buildDefinition?.resolvedDependencies?.some(
|
||||
(dependency) =>
|
||||
dependency.uri === expectedDependencyUri && dependency.digest?.gitCommit === expectedSha,
|
||||
) === true;
|
||||
if (!protectedReleasePublishTrusted) {
|
||||
throw new Error(
|
||||
"npm provenance does not bind the approved SHA-pinned release-publish ref to its workflow revision.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const isTrustedRef =
|
||||
workflowRef === "refs/heads/main" ||
|
||||
workflowRef === expectedReleaseRef ||
|
||||
protectedReleasePublishTrusted ||
|
||||
(parsedVersion.channel === "alpha" &&
|
||||
/^refs\/heads\/tideclaw\/alpha\/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$/u.test(
|
||||
workflowRef ?? "",
|
||||
@@ -320,6 +358,8 @@ async function verifySigstoreNpmProvenanceBundle(
|
||||
|
||||
export async function verifyNpmProvenanceAttestation(params: {
|
||||
attestations: NpmRegistryAttestation[];
|
||||
expectedWorkflowRef?: string;
|
||||
expectedWorkflowSha?: string;
|
||||
integrity: string;
|
||||
packageName: string;
|
||||
verifyBundle?: VerifyNpmProvenanceBundle;
|
||||
@@ -353,7 +393,10 @@ export async function verifyNpmProvenanceAttestation(params: {
|
||||
) {
|
||||
let policy: NpmProvenanceVerificationPolicy;
|
||||
try {
|
||||
policy = resolveNpmProvenanceVerificationPolicy(statement, params.version);
|
||||
policy = resolveNpmProvenanceVerificationPolicy(statement, params.version, {
|
||||
ref: params.expectedWorkflowRef,
|
||||
sha: params.expectedWorkflowSha,
|
||||
});
|
||||
} catch (error) {
|
||||
policyError = error;
|
||||
continue;
|
||||
@@ -1156,6 +1199,8 @@ async function verifyPublishedRegistryProvenanceOnce(version: string): Promise<v
|
||||
version,
|
||||
integrity,
|
||||
attestations,
|
||||
expectedWorkflowRef: process.env.OPENCLAW_NPM_EXPECTED_WORKFLOW_REF,
|
||||
expectedWorkflowSha: process.env.OPENCLAW_NPM_EXPECTED_WORKFLOW_SHA,
|
||||
});
|
||||
console.log(
|
||||
`openclaw-npm-postpublish-verify: registry signature and provenance attestation verified (${version})`,
|
||||
|
||||
@@ -228,6 +228,107 @@ describe("npm registry provenance verification", () => {
|
||||
"https://github.com/openclaw/openclaw/.github/workflows/openclaw-npm-release.yml@refs/heads/release/2026.3.23",
|
||||
});
|
||||
|
||||
verificationPolicy = undefined;
|
||||
const protectedWorkflowSha = "a".repeat(40);
|
||||
const protectedWorkflowRef = `refs/tags/release-publish/${protectedWorkflowSha.slice(0, 12)}-123`;
|
||||
await expect(
|
||||
verifyNpmProvenanceAttestation({
|
||||
packageName,
|
||||
version,
|
||||
integrity,
|
||||
attestations: [
|
||||
{
|
||||
predicateType: "https://slsa.dev/provenance/v1",
|
||||
bundle: {
|
||||
dsseEnvelope: {
|
||||
payload: Buffer.from(
|
||||
JSON.stringify({
|
||||
...provenancePayload,
|
||||
predicate: {
|
||||
...provenancePayload.predicate,
|
||||
buildDefinition: {
|
||||
...provenancePayload.predicate.buildDefinition,
|
||||
externalParameters: {
|
||||
workflow: {
|
||||
...provenancePayload.predicate.buildDefinition.externalParameters
|
||||
.workflow,
|
||||
ref: protectedWorkflowRef,
|
||||
},
|
||||
},
|
||||
resolvedDependencies: [
|
||||
{
|
||||
uri: `git+https://github.com/openclaw/openclaw@${protectedWorkflowRef}`,
|
||||
digest: { gitCommit: protectedWorkflowSha },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
).toString("base64"),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
expectedWorkflowRef: protectedWorkflowRef,
|
||||
expectedWorkflowSha: protectedWorkflowSha,
|
||||
verifyBundle: async (_bundle, policy) => {
|
||||
verificationPolicy = policy;
|
||||
},
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(verificationPolicy).toEqual({
|
||||
certificateIssuer: "https://token.actions.githubusercontent.com",
|
||||
certificateIdentityURI: `https://github.com/openclaw/openclaw/.github/workflows/openclaw-npm-release.yml@${protectedWorkflowRef}`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyNpmProvenanceAttestation({
|
||||
packageName,
|
||||
version,
|
||||
integrity,
|
||||
attestations: [
|
||||
{
|
||||
predicateType: "https://slsa.dev/provenance/v1",
|
||||
bundle: {
|
||||
dsseEnvelope: {
|
||||
payload: Buffer.from(
|
||||
JSON.stringify({
|
||||
...provenancePayload,
|
||||
predicate: {
|
||||
...provenancePayload.predicate,
|
||||
buildDefinition: {
|
||||
...provenancePayload.predicate.buildDefinition,
|
||||
externalParameters: {
|
||||
workflow: {
|
||||
...provenancePayload.predicate.buildDefinition.externalParameters
|
||||
.workflow,
|
||||
ref: protectedWorkflowRef,
|
||||
},
|
||||
},
|
||||
resolvedDependencies: [
|
||||
{
|
||||
uri: `git+https://github.com/openclaw/openclaw@${protectedWorkflowRef}`,
|
||||
digest: { gitCommit: protectedWorkflowSha },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
).toString("base64"),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
expectedWorkflowRef: protectedWorkflowRef,
|
||||
expectedWorkflowSha: "b".repeat(40),
|
||||
verifyBundle: async () => undefined,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"npm provenance SHA-pinned release-publish ref does not match the approved workflow ref and SHA",
|
||||
);
|
||||
|
||||
await expect(
|
||||
verifyNpmProvenanceAttestation({
|
||||
packageName,
|
||||
|
||||
@@ -27,6 +27,7 @@ const DOCKER_E2E_PLAN_ACTION = ".github/actions/docker-e2e-plan/action.yml";
|
||||
const RELEASE_CHECKS_WORKFLOW = ".github/workflows/openclaw-release-checks.yml";
|
||||
const RELEASE_TELEGRAM_QA_WORKFLOW = ".github/workflows/openclaw-release-telegram-qa.yml";
|
||||
const RELEASE_PUBLISH_WORKFLOW = ".github/workflows/openclaw-release-publish.yml";
|
||||
const OPENCLAW_NPM_RELEASE_WORKFLOW = ".github/workflows/openclaw-npm-release.yml";
|
||||
const PLUGIN_CLAWHUB_RELEASE_WORKFLOW = ".github/workflows/plugin-clawhub-release.yml";
|
||||
const PLUGIN_NPM_RELEASE_WORKFLOW = ".github/workflows/plugin-npm-release.yml";
|
||||
const ANDROID_RELEASE_WORKFLOW = ".github/workflows/android-release.yml";
|
||||
@@ -250,6 +251,33 @@ function runReleasePublishInputValidation(overrides: Record<string, string>) {
|
||||
});
|
||||
}
|
||||
|
||||
function runOpenClawNpmTrustedRefGuard(overrides: Record<string, string>) {
|
||||
const job = workflowJob(OPENCLAW_NPM_RELEASE_WORKFLOW, "validate_publish_request");
|
||||
const script = workflowStep(job, "Require trusted workflow ref for publish").run;
|
||||
if (!script) {
|
||||
throw new Error("Expected OpenClaw npm trusted ref guard");
|
||||
}
|
||||
const binDir = tempDirs.make("openclaw-npm-trusted-ref-");
|
||||
const gitPath = `${binDir}/git`;
|
||||
writeFileSync(
|
||||
gitPath,
|
||||
`#!/bin/sh\nif [ "$1" = "fetch" ]; then exit 0; fi\nif [ "$1" = "merge-base" ]; then [ "\${MOCK_WORKFLOW_ANCESTOR}" = "true" ]; exit $?; fi\nexit 2\n`,
|
||||
);
|
||||
chmodSync(gitPath, 0o755);
|
||||
return spawnSync("bash", ["-c", script], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
MOCK_WORKFLOW_ANCESTOR: "true",
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
RELEASE_NPM_DIST_TAG: "beta",
|
||||
RELEASE_TAG: "v2026.7.2-beta.1",
|
||||
WORKFLOW_REF: "refs/heads/release/2026.7.2",
|
||||
WORKFLOW_SHA: "a".repeat(40),
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function runReleaseChecksSummary(params: {
|
||||
currentAttempt: string;
|
||||
currentResult: "cancelled" | "failure" | "skipped" | "success";
|
||||
@@ -381,6 +409,64 @@ describe("package acceptance workflow", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("allows protected SHA-pinned tooling tags through the core npm publish guard", () => {
|
||||
const workflowSha = "a".repeat(40);
|
||||
const protectedRef = `refs/tags/release-publish/${workflowSha.slice(0, 12)}-123`;
|
||||
|
||||
const valid = runOpenClawNpmTrustedRefGuard({
|
||||
WORKFLOW_REF: protectedRef,
|
||||
WORKFLOW_SHA: workflowSha,
|
||||
});
|
||||
expect(valid.status, valid.stderr).toBe(0);
|
||||
|
||||
const mismatchedName = runOpenClawNpmTrustedRefGuard({
|
||||
WORKFLOW_REF: `refs/tags/release-publish/${"b".repeat(12)}-123`,
|
||||
WORKFLOW_SHA: workflowSha,
|
||||
});
|
||||
expect(mismatchedName.status).toBe(1);
|
||||
expect(mismatchedName.stderr).toContain(
|
||||
"SHA-pinned release-publish tag does not match the OpenClaw npm workflow SHA",
|
||||
);
|
||||
|
||||
const unreachable = runOpenClawNpmTrustedRefGuard({
|
||||
MOCK_WORKFLOW_ANCESTOR: "false",
|
||||
WORKFLOW_REF: protectedRef,
|
||||
WORKFLOW_SHA: workflowSha,
|
||||
});
|
||||
expect(unreachable.status).toBe(1);
|
||||
expect(unreachable.stderr).toContain(
|
||||
"SHA-pinned OpenClaw npm workflow revision is not reachable from current main",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows protected SHA-pinned tooling tags to consume token-bootstrap evidence", () => {
|
||||
const publishJob = workflowJob(PLUGIN_NPM_RELEASE_WORKFLOW, "publish_plugins_npm");
|
||||
const evidenceStep = workflowStep(publishJob, "Consume immutable npm publication evidence");
|
||||
|
||||
expect(evidenceStep.run).toContain("^refs/tags/release-publish/([a-f0-9]{12})-[1-9][0-9]*$");
|
||||
expect(evidenceStep.run).toContain(
|
||||
'[[ "$WORKFLOW_REF" == "refs/heads/main" || "$sha_pinned_release_publish" == "true" ]]',
|
||||
);
|
||||
expect(evidenceStep.run).toContain('git merge-base --is-ancestor "$WORKFLOW_SHA" origin/main');
|
||||
});
|
||||
|
||||
it("retries child environment approval when deployment propagation lags", () => {
|
||||
const publishJob = workflowJob(RELEASE_PUBLISH_WORKFLOW, "publish");
|
||||
const orchestration = workflowStep(publishJob, "Dispatch publish workflows").run;
|
||||
if (!orchestration) {
|
||||
throw new Error("Expected release publish orchestration script");
|
||||
}
|
||||
const waitForRun = shellFunctionSource(orchestration, "wait_for_run");
|
||||
const stateAssignment = waitForRun.indexOf('last_state="$state"');
|
||||
const approvalRetry = waitForRun.indexOf(
|
||||
'approve_pending_deployments "${workflow}" "${run_id}" "${expected_sha}" ||',
|
||||
);
|
||||
|
||||
expect(stateAssignment).toBeGreaterThan(-1);
|
||||
expect(approvalRetry).toBeGreaterThan(stateAssignment);
|
||||
expect(waitForRun).toContain("propagation lag cannot strand an approved release");
|
||||
});
|
||||
|
||||
it("resolves broad release evidence and exact-binds every publish child", () => {
|
||||
const resolveJob = workflowJob(RELEASE_PUBLISH_WORKFLOW, "resolve_release_target");
|
||||
const publishJob = workflowJob(RELEASE_PUBLISH_WORKFLOW, "publish");
|
||||
@@ -3784,6 +3870,10 @@ describe("package artifact reuse", () => {
|
||||
'"${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha"',
|
||||
);
|
||||
expect(releaseWorkflow).toContain('--workflow-ref "${CHILD_WORKFLOW_REF}"');
|
||||
expect(releaseWorkflow).toContain('OPENCLAW_NPM_EXPECTED_WORKFLOW_REF="${GITHUB_REF}"');
|
||||
expect(releaseWorkflow).toContain(
|
||||
'OPENCLAW_NPM_EXPECTED_WORKFLOW_SHA="${PARENT_WORKFLOW_SHA}"',
|
||||
);
|
||||
expect(releaseWorkflow).toContain("--skip-github-release");
|
||||
expect(clawHubReleasePlanScript).toContain("--plugin-clawhub-bootstrap-run");
|
||||
expect(releaseWorkflow).toContain('verify_args+=(--plugins "${PLUGINS}")');
|
||||
|
||||
Reference in New Issue
Block a user