Skip to content

[new-plugin] cross-hedge-alpha #1031

[new-plugin] cross-hedge-alpha

[new-plugin] cross-hedge-alpha #1031

Workflow file for this run

# Phase 2: Structural Validation
# Uses pull_request_target to have write permissions for PR comments/labels
# even when the PR comes from an external contributor (fork).
#
# SECURITY: Split into isolated jobs to prevent Pwn Request attacks.
# - gate: blocks fork PRs that modify .github/
# - collect: zero permissions, checkouts fork code, collects data as artifact
# - lint: has write perms, only checkouts main, runs lint against artifact data
name: "Phase 1: Structure Validation"
on:
pull_request_target:
paths:
- 'skills/**'
types: [opened, synchronize, reopened]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# ═══════════════════════════════════════════════════════════════
# Security Gate β€” block fork PRs that modify .github/
# ═══════════════════════════════════════════════════════════════
gate:
name: Security gate
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
safe: ${{ steps.check.outputs.safe }}
steps:
- uses: actions/checkout@v4
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
fetch-depth: 0
- name: Block .github modifications from forks
id: check
run: |
git remote add base https://github.com/${{ github.event.pull_request.base.repo.full_name }}.git 2>/dev/null || true
git fetch base ${{ github.event.pull_request.base.ref }} 2>/dev/null || true
echo "Checking PR for .github/ modifications..."
CHANGES=$(git diff --name-only base/${{ github.event.pull_request.base.ref }}...${{ github.event.pull_request.head.sha }} | grep "^\.github/" || true)
if [ -n "$CHANGES" ]; then
echo "::error::Fork PRs cannot modify .github/ files:"
echo "$CHANGES"
echo "safe=false" >> "$GITHUB_OUTPUT"
exit 1
fi
echo "No .github/ modifications detected"
echo "safe=true" >> "$GITHUB_OUTPUT"
# ═══════════════════════════════════════════════════════════════
# Collect β€” zero permissions sandbox, checkout fork code
# ═══════════════════════════════════════════════════════════════
collect:
name: Collect plugin data
needs: gate
runs-on: ubuntu-latest
permissions: {}
outputs:
plugin_dir: ${{ steps.find.outputs.plugin_dir }}
plugin_name: ${{ steps.find.outputs.plugin_name }}
is_new: ${{ steps.find.outputs.is_new }}
steps:
# Checkout the PR code (not main) for inspection
- uses: actions/checkout@v4
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
fetch-depth: 0
- name: Fetch base branch for diff
run: |
git remote add base https://github.com/${{ github.event.pull_request.base.repo.full_name }}.git 2>/dev/null || true
git fetch base ${{ github.event.pull_request.base.ref }} 2>/dev/null || true
- name: Find changed plugin directory
id: find
run: |
CHANGED=$(git diff --name-only base/${{ github.event.pull_request.base.ref }}...${{ github.event.pull_request.head.sha }} -- 'skills/' | head -100)
if [ -z "$CHANGED" ]; then
echo "No changes in skills/"
exit 1
fi
PLUGIN_NAME=$(echo "$CHANGED" | head -1 | cut -d'/' -f2)
# Validate plugin name (prevent injection via malicious folder names)
if ! echo "$PLUGIN_NAME" | grep -qE '^[a-zA-Z0-9_-]+$'; then
echo "::error::Invalid plugin name: contains special characters"
exit 1
fi
PLUGIN_DIR="skills/${PLUGIN_NAME}"
echo "plugin_dir=${PLUGIN_DIR}" >> "$GITHUB_OUTPUT"
echo "plugin_name=${PLUGIN_NAME}" >> "$GITHUB_OUTPUT"
if git show origin/main:"${PLUGIN_DIR}/plugin.yaml" > /dev/null 2>&1; then
echo "is_new=false" >> "$GITHUB_OUTPUT"
else
echo "is_new=true" >> "$GITHUB_OUTPUT"
fi
echo "Plugin: ${PLUGIN_NAME} (dir: ${PLUGIN_DIR})"
- name: Verify PR only modifies one plugin
run: |
CHANGED=$(git diff --name-only base/${{ github.event.pull_request.base.ref }}...${{ github.event.pull_request.head.sha }} -- 'skills/')
DIRS=$(echo "$CHANGED" | cut -d'/' -f2 | sort -u)
COUNT=$(echo "$DIRS" | wc -l | tr -d ' ')
if [ "$COUNT" -gt 1 ]; then
echo "::error::PR modifies multiple plugins: $(echo $DIRS | tr '\n' ', ')."
exit 1
fi
- name: Verify PR does not modify files outside skills/
run: |
OUTSIDE=$(git diff --name-only base/${{ github.event.pull_request.base.ref }}...${{ github.event.pull_request.head.sha }} | grep -v '^skills/' || true)
if [ -n "$OUTSIDE" ]; then
echo "::error::PR modifies files outside skills/: ${OUTSIDE}."
exit 1
fi
# ── Collect plugin files as artifact ──────────
- name: Collect plugin files
run: |
mkdir -p /tmp/plugin-data
PLUGIN_DIR="skills/${{ steps.find.outputs.plugin_name }}"
if [ -d "$PLUGIN_DIR" ]; then
cp -r "$PLUGIN_DIR" /tmp/plugin-data/
fi
- uses: actions/upload-artifact@v4
with:
name: plugin-data
path: /tmp/plugin-data/
# ═══════════════════════════════════════════════════════════════
# Lint β€” privileged job, only checkouts main (trusted code)
# ═══════════════════════════════════════════════════════════════
lint:
name: Structure validation
needs: collect
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
# No repository/ref override = checkouts main (trusted code)
- uses: actions/download-artifact@v4
with:
name: plugin-data
path: /tmp/plugin-data/
# ── Reconstruct plugin dir from artifact ──────────
- name: Setup plugin data
run: |
PLUGIN_NAME="${{ needs.collect.outputs.plugin_name }}"
PLUGIN_DIR="${{ needs.collect.outputs.plugin_dir }}"
mkdir -p "${PLUGIN_DIR}"
if [ -d "/tmp/plugin-data/${PLUGIN_NAME}" ]; then
cp -r "/tmp/plugin-data/${PLUGIN_NAME}/." "${PLUGIN_DIR}/"
fi
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-lint-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-lint-
- name: Install plugin-store CLI
run: cargo install --git https://github.com/okx/plugin-store.git plugin-store
- name: Check if PR author is OKX org member
id: org_check
run: |
AUTHOR="$PR_AUTHOR"
HEAD_REPO="$PR_HEAD_REPO"
# Method 1: PR comes from okx/ org repo (direct push)
if echo "$HEAD_REPO" | grep -q "^okx/"; then
echo "is_okx_member=true" >> "$GITHUB_OUTPUT"
echo "${AUTHOR} pushed from okx/ org repo β€” official prefix allowed"
exit 0
fi
# Method 2: Check if author is a public member of okx org
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
"https://api.github.com/orgs/okx/members/${AUTHOR}" \
-H "Authorization: Bearer ${{ github.token }}")
if [ "$HTTP_CODE" = "204" ]; then
echo "is_okx_member=true" >> "$GITHUB_OUTPUT"
echo "${AUTHOR} is an OKX org member β€” official prefix allowed"
else
echo "is_okx_member=false" >> "$GITHUB_OUTPUT"
echo "${AUTHOR} is not an OKX org member (HTTP ${HTTP_CODE})"
fi
- name: Check name uniqueness
id: name_check
run: |
PLUGIN_NAME="${{ needs.collect.outputs.plugin_name }}"
IS_NEW="${{ needs.collect.outputs.is_new }}"
if [ "$IS_NEW" != "true" ]; then
echo "Plugin update (not new) β€” skip uniqueness check"
echo "duplicate=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Fetch current registry and check for duplicate name
REGISTRY=$(curl -sSL --max-time 10 \
"https://raw.githubusercontent.com/okx/plugin-store/main/registry.json" 2>/dev/null || echo '{"plugins":[]}')
EXISTING=$(echo "$REGISTRY" | jq -r '.plugins[].name' 2>/dev/null)
if echo "$EXISTING" | grep -qx "$PLUGIN_NAME"; then
echo "::error::Plugin name '${PLUGIN_NAME}' already exists in registry. Choose a different name."
echo "duplicate=true" >> "$GITHUB_OUTPUT"
else
echo "Name '${PLUGIN_NAME}' is unique β€” OK"
echo "duplicate=false" >> "$GITHUB_OUTPUT"
fi
- name: Run lint
id: lint
env:
PLUGIN_STORE_OFFICIAL: ${{ steps.org_check.outputs.is_okx_member == 'true' && '1' || '0' }}
run: |
set +e
OUTPUT=$(plugin-store lint "${{ needs.collect.outputs.plugin_dir }}" 2>&1)
EXIT_CODE=$?
set -e
# Check dependent_plugin references exist in registry (strategy plugins only)
PLUGIN_DIR="${{ needs.collect.outputs.plugin_dir }}"
YAML_FILE="${PLUGIN_DIR}/plugin.yaml"
CATEGORY=$(yq '.category // ""' "$YAML_FILE" 2>/dev/null || echo "")
DEP_COUNT=$(yq '.dependent_plugin | length' "$YAML_FILE" 2>/dev/null || echo "0")
if [ "$CATEGORY" = "strategy" ] && [ "$DEP_COUNT" -gt 0 ]; then
echo "Strategy plugin detected β€” checking dependent_plugin..."
REGISTRY=$(curl -sSL --max-time 10 \
"https://raw.githubusercontent.com/okx/plugin-store/main/registry.json" 2>/dev/null || echo '{"plugins":[]}')
REG_NAMES=$(echo "$REGISTRY" | jq -r '.plugins[].name' 2>/dev/null)
for i in $(seq 0 $((DEP_COUNT - 1))); do
DEP_NAME=$(yq ".dependent_plugin[$i].name" "$YAML_FILE" 2>/dev/null)
if ! echo "$REG_NAMES" | grep -qx "$DEP_NAME"; then
DEP_MSG=" E160: dependent_plugin '${DEP_NAME}' not found in registry"
OUTPUT=$(printf '%s\n%s' "$OUTPUT" "$DEP_MSG")
EXIT_CODE=1
else
echo " βœ“ ${DEP_NAME} found in registry"
fi
done
fi
# Append name uniqueness result
if [ "${{ steps.name_check.outputs.duplicate }}" = "true" ]; then
DUPE_MSG=" E034: name '${{ needs.collect.outputs.plugin_name }}' already exists in registry"
OUTPUT=$(printf '%s\n%s' "$OUTPUT" "$DUPE_MSG")
EXIT_CODE=1
fi
echo "$OUTPUT"
{
echo 'lint_output<<LINT_EOF'
echo "$OUTPUT"
echo 'LINT_EOF'
} >> "$GITHUB_OUTPUT"
echo "exit_code=${EXIT_CODE}" >> "$GITHUB_OUTPUT"
- name: Add labels
if: always()
uses: actions/github-script@v7
env:
IS_NEW: ${{ needs.collect.outputs.is_new }}
LINT_EXIT_CODE: ${{ steps.lint.outputs.exit_code }}
with:
script: |
const isNew = process.env.IS_NEW === 'true';
const passed = process.env.LINT_EXIT_CODE === '0';
const prNumber = ${{ github.event.pull_request.number }};
const typeLabel = isNew ? 'new-plugin' : 'plugin-update';
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: [typeLabel]
});
} catch (e) { console.log('Label error:', e.message); }
if (passed) {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: prNumber, labels: ['structure-validated']
});
await github.rest.issues.removeLabel({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: prNumber, name: 'needs-fix'
});
} catch (e) { /* label not present */ }
} else {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: prNumber, labels: ['needs-fix']
});
await github.rest.issues.removeLabel({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: prNumber, name: 'structure-validated'
});
} catch (e) { /* label not present */ }
}
- name: Post lint report as PR comment
if: always()
uses: actions/github-script@v7
env:
LINT_OUTPUT: ${{ steps.lint.outputs.lint_output }}
LINT_EXIT_CODE: ${{ steps.lint.outputs.exit_code }}
with:
script: |
const output = (process.env.LINT_OUTPUT || '').replace(/`/g, '\\`');
const passed = process.env.LINT_EXIT_CODE === '0';
const icon = passed ? 'βœ…' : '❌';
const status = passed ? 'PASSED' : 'FAILED';
const prNumber = ${{ github.event.pull_request.number }};
const body = `## ${icon} Phase 1: Structure Validation β€” ${status}\n\n\`\`\`\n${output}\n\`\`\`\n\n${passed ? 'β†’ Proceeding to Phase 2: Build Verification' : 'β†’ Please fix the errors above and push again.'}`;
try {
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: prNumber,
});
const botComment = comments.find(c =>
c.user.type === 'Bot' && c.body.includes('Phase 1: Structure Validation')
);
const params = { owner: context.repo.owner, repo: context.repo.repo, body };
if (botComment) {
await github.rest.issues.updateComment({ ...params, comment_id: botComment.id });
} else {
await github.rest.issues.createComment({ ...params, issue_number: prNumber });
}
console.log('Phase 1 comment posted successfully');
} catch (e) {
console.log('Failed to post Phase 1 comment:', e.message);
core.warning('Failed to post Phase 1 lint report: ' + e.message);
}
- name: Fail if lint errors
if: steps.lint.outputs.exit_code != '0'
run: exit 1