name: social / copy-generator on: pull_request: types: [opened] issue_comment: types: [edited] workflow_dispatch: inputs: pr_number: description: "PR number to generate social copies for" required: true type: number permissions: contents: read jobs: post-comment: if: >- (github.event_name == 'workflow_dispatch') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest permissions: contents: read pull-requests: write issues: write steps: - name: Post initial comment uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.pull_request.number }} with: script: | const prNumber = Number(process.env.PR_NUMBER); // For workflow_dispatch, verify the PR isn't from a fork if (context.eventName === 'workflow_dispatch') { const pr = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, }); if (pr.data.head.repo?.full_name !== `${context.repo.owner}/${context.repo.repo}`) { console.log('PR is from a fork, skipping'); return; } } // Check if there's already a social-copy-generator comment on this PR const comments = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, per_page: 100, }); const existing = comments.data.find(c => c.body.includes('')); if (existing) { console.log(`Comment already exists (id: ${existing.id}), skipping`); return; } await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, body: [ '', '### 📣 Social Copy Generator', '', 'Generate social media copies (Twitter/X, LinkedIn, Blog Post) for this PR using Claude.', '', '- [ ] **Generate social media copies**', ].join('\n'), }); generate: if: >- github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '') && contains(github.event.comment.body, '- [x]') runs-on: ubuntu-latest permissions: contents: read pull-requests: write issues: write concurrency: group: social-copy-${{ github.event.issue.number }} cancel-in-progress: true steps: - name: Verify checkbox transition and permissions id: verify uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | // Check that the sender has write access (not an external collaborator or random user) const sender = context.payload.sender.login; try { const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ owner: context.repo.owner, repo: context.repo.repo, username: sender, }); const allowed = ['admin', 'write']; if (!allowed.includes(permissionLevel.permission)) { core.setOutput('should_run', 'false'); console.log(`User ${sender} has '${permissionLevel.permission}' permission, skipping`); return; } } catch (e) { core.setOutput('should_run', 'false'); console.log(`Could not verify permissions for ${sender}, skipping`); return; } const oldBody = context.payload.changes?.body?.from || ''; const newBody = context.payload.comment.body; const wasUnchecked = oldBody.includes('- [ ]'); const isNowChecked = newBody.includes('- [x]'); if (!wasUnchecked || !isNowChecked) { core.setOutput('should_run', 'false'); console.log('Not a checkbox transition, skipping'); return; } core.setOutput('should_run', 'true'); core.setOutput('comment_id', context.payload.comment.id); - name: Update comment to generating state if: steps.verify.outputs.should_run == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: COMMENT_ID: ${{ steps.verify.outputs.comment_id }} with: script: | const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: Number(process.env.COMMENT_ID), body: [ '', '### 📣 Social Copy Generator', '', `⏳ **Analyzing PR changes with Claude...** This may take a minute. [View progress](${runUrl})`, ].join('\n'), }); - name: Get PR details if: steps.verify.outputs.should_run == 'true' id: pr uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const pr = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: context.issue.number, }); core.setOutput('head_ref', pr.data.head.ref); core.setOutput('head_sha', pr.data.head.sha); core.setOutput('base_ref', pr.data.base.ref); core.setOutput('title', pr.data.title); core.setOutput('body', pr.data.body || '(no description)'); core.setOutput('number', String(context.issue.number)); - name: Checkout repo if: steps.verify.outputs.should_run == 'true' uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ steps.pr.outputs.head_sha }} fetch-depth: 0 persist-credentials: false - name: Remove repository Claude settings if: steps.verify.outputs.should_run == 'true' run: rm -f .claude/settings.json .claude/settings.local.json .mcp.json - name: Get PR diff if: steps.verify.outputs.should_run == 'true' id: diff env: BASE_REF: ${{ steps.pr.outputs.base_ref }} PR_NUMBER: ${{ steps.pr.outputs.number }} GH_TOKEN: ${{ github.token }} run: | # Write diff files inside the repo so Claude's sandbox can access them mkdir -p .claude-tmp # Try git merge-base first (works for open PRs and merged PRs whose base is fetchable) MERGE_BASE=$(git merge-base "origin/$BASE_REF" HEAD 2>/dev/null) || true if [ -n "$MERGE_BASE" ]; then git diff --stat "$MERGE_BASE"..HEAD > .claude-tmp/diff-stat.txt git diff "$MERGE_BASE"..HEAD > .claude-tmp/diff-full.txt else # Fallback: use GitHub API diff (works for merged PRs where branch was deleted) echo "Using GitHub API for diff (merge-base not found)" gh pr diff "$PR_NUMBER" --patch > .claude-tmp/diff-full.txt gh pr diff "$PR_NUMBER" | head -200 > .claude-tmp/diff-stat.txt fi - name: Setup pnpm if: steps.verify.outputs.should_run == 'true' # Omit `version:` so pnpm/action-setup inherits from the repo's # `packageManager` field in package.json (via corepack). uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Setup Node.js if: steps.verify.outputs.should_run == 'true' uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: pnpm - name: Install Claude Code if: steps.verify.outputs.should_run == 'true' # @anthropic-ai/claude-code is pinned as a root devDependency and installed # from the frozen lockfile — no ad-hoc `npm install -g`. `--ignore-scripts` # keeps install-time scripts from running against the PR-head checkout; the # `cli-wrapper.cjs` entrypoint (used below) resolves the native binary from # the installed optionalDependency without needing the postinstall step. run: pnpm install --frozen-lockfile --ignore-scripts - name: Write prompt file if: steps.verify.outputs.should_run == 'true' env: PR_TITLE: ${{ steps.pr.outputs.title }} PR_BRANCH: ${{ steps.pr.outputs.head_ref }} PR_BODY: ${{ steps.pr.outputs.body }} run: | cat << 'PROMPT_EOF' > .claude-tmp/prompt.txt You are a developer advocate and social media copywriter for CopilotKit — an open-source AI agent framework that lets developers add AI copilots to their products with React hooks, UI components, and agent infrastructure. CopilotKit connects frontend (React/Angular), runtime (Express/Hono), and AI agents (LangGraph, CrewAI, custom) via the AG-UI protocol. A PR has been opened. Your job is to analyze the changes and generate social media copy that the team can use to announce this work. ## PR Information PROMPT_EOF echo "- **Title:** $PR_TITLE" >> .claude-tmp/prompt.txt echo "- **Branch:** $PR_BRANCH" >> .claude-tmp/prompt.txt echo "- **Description:**" >> .claude-tmp/prompt.txt echo "$PR_BODY" >> .claude-tmp/prompt.txt cat << 'PROMPT_EOF' >> .claude-tmp/prompt.txt ## Instructions 1. Read the diff stat file at `.claude-tmp/diff-stat.txt` to understand the scope of changes. 2. Read the full diff at `.claude-tmp/diff-full.txt` to understand the details. 3. If needed, read relevant source files to understand context. 4. Generate the following three pieces of content: ### Output Format Produce your output in EXACTLY this format (including the markdown headers). Do NOT include any preamble, analysis summary, or commentary before or after the three sections. Start directly with the first header: ### 🐦 Twitter/X Post (A concise, engaging tweet under 280 characters. No hashtags. Focus on the user benefit.) ### 💼 LinkedIn Post (A professional post of 3-5 short paragraphs. Lead with the value proposition. Include a call to action. Add relevant hashtags. No length limit.) ### 📝 Blog Post Draft (A blog-style announcement. Include a title, explain what changed, why it matters, and how to get started. No length limit — be as thorough as needed.) ## Guidelines - Focus on user-facing impact, not implementation details - Be enthusiastic but authentic — avoid hype - Mention CopilotKit by name and link to https://github.com/CopilotKit/CopilotKit - If the changes are internal/minor, still generate copy but note it's a maintenance/improvement update PROMPT_EOF - name: Run Claude Code if: steps.verify.outputs.should_run == 'true' id: claude env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | PROMPT=$(cat .claude-tmp/prompt.txt) # Invoke the workspace-installed Claude Code via its cli-wrapper entrypoint. # We installed with --ignore-scripts (PR-head safety), so the postinstall # that copies the native binary over the `claude` bin stub did not run; # cli-wrapper.cjs is the package's documented fallback that resolves and # spawns the native binary from the installed optionalDependency. node node_modules/@anthropic-ai/claude-code/cli-wrapper.cjs -p "$PROMPT" \ --output-format json \ --max-turns 10 \ --allowedTools "Read,Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(cat:*)" \ --disallowedTools "TodoWrite,Edit,MultiEdit,Write,NotebookEdit,WebFetch,WebSearch,Task" \ > .claude-tmp/claude-output.json - name: Extract result to markdown if: steps.verify.outputs.should_run == 'true' && always() && steps.claude.outcome != 'skipped' id: extract run: | node << 'EXTRACT_SCRIPT' > .claude-tmp/pr-social-copy.md const fs = require('fs'); const file = '.claude-tmp/claude-output.json'; if (!fs.existsSync(file)) { process.exit(0); } const raw = fs.readFileSync(file, 'utf8').trim(); let result = ''; // With --output-format json, output is a single JSON object with a "result" field try { const obj = JSON.parse(raw); result = obj.result || ''; } catch { // Fallback: try JSONL format (one JSON object per line) for (const line of raw.split('\n')) { try { const obj = JSON.parse(line); if (obj.type === 'result' && obj.result) { result = obj.result; } if (!result && obj.type === 'assistant' && obj.message?.content) { for (const block of obj.message.content) { if (block.type === 'text' && block.text && block.text.includes('### ')) { result = block.text; } } } } catch {} } } // Strip any preamble before the first ### header const idx = result.indexOf('### '); const clean = idx >= 0 ? result.slice(idx) : result; process.stdout.write(clean); EXTRACT_SCRIPT - name: Upload social copy artifact if: steps.verify.outputs.should_run == 'true' && always() && steps.extract.outcome == 'success' id: artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: social-copy-pr${{ github.event.issue.number }} path: .claude-tmp/pr-social-copy.md retention-days: 90 - name: Update comment with results if: steps.verify.outputs.should_run == 'true' && always() && steps.extract.outcome == 'success' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: HEAD_SHA: ${{ steps.pr.outputs.head_sha }} COMMENT_ID: ${{ steps.verify.outputs.comment_id }} ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }} with: script: | const fs = require('fs'); const output = fs.readFileSync('.claude-tmp/pr-social-copy.md', 'utf8').trim() || '_Claude did not produce output. Please try again._'; const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC'; const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; const artifactUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/artifacts/${process.env.ARTIFACT_ID}`; await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: Number(process.env.COMMENT_ID), body: [ '', '### 📣 Social Copy Generator', '', output, '', '---', `_Generated at ${timestamp} from commit \`${process.env.HEAD_SHA}\` | [Workflow run](${runUrl}) | [Download markdown](${artifactUrl})_`, '', '- [ ] **Regenerate social media copies**', ].join('\n'), }); - name: Update comment on failure if: steps.verify.outputs.should_run == 'true' && always() && steps.extract.outcome != 'success' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: COMMENT_ID: ${{ steps.verify.outputs.comment_id }} with: script: | const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: Number(process.env.COMMENT_ID), body: [ '', '### 📣 Social Copy Generator', '', `❌ **Generation failed.** [View workflow logs](${runUrl}) for details.`, '', '- [ ] **Regenerate social media copies**', ].join('\n'), });