[new-plugin] cross-hedge-alpha #1031
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |