diff --git a/.github/workflows/pkg.pr.new-comment.yml b/.github/workflows/pkg.pr.new-comment.yml deleted file mode 100644 index 64495cc5c8..0000000000 --- a/.github/workflows/pkg.pr.new-comment.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Update pkg.pr.new comment - -on: - workflow_run: - workflows: ['Publish Any Commit'] - types: - - completed - -permissions: - pull-requests: write - -jobs: - build: - name: 'Update comment' - runs-on: ubuntu-latest - steps: - - name: Download artifact - uses: actions/download-artifact@v7 - with: - name: output - github-token: ${{ secrets.GITHUB_TOKEN }} - run-id: ${{ github.event.workflow_run.id }} - - - run: ls -R . - - name: 'Post or update comment' - uses: actions/github-script@v8 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - const output = JSON.parse(fs.readFileSync('output.json', 'utf8')); - - const bot_comment_identifier = ``; - - const body = (number) => `${bot_comment_identifier} - - [Playground](https://svelte.dev/playground?version=pr-${number}) - - \`\`\` - ${output.packages.map((p) => `pnpm add https://pkg.pr.new/${p.name}@${number}`).join('\n')} - \`\`\` - `; - - async function find_bot_comment(issue_number) { - if (!issue_number) return null; - const comments = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue_number, - }); - return comments.data.find((comment) => - comment.body.includes(bot_comment_identifier) - ); - } - - async function create_or_update_comment(issue_number) { - if (!issue_number) { - console.log('No issue number provided. Cannot post or update comment.'); - return; - } - - const existing_comment = await find_bot_comment(issue_number); - if (existing_comment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing_comment.id, - body: body(issue_number), - }); - } else { - await github.rest.issues.createComment({ - issue_number: issue_number, - owner: context.repo.owner, - repo: context.repo.repo, - body: body(issue_number), - }); - } - } - - async function log_publish_info() { - const svelte_package = output.packages.find(p => p.name === 'svelte'); - const svelte_sha = svelte_package.url.replace(/^.+@([^@]+)$/, '$1'); - console.log('\n' + '='.repeat(50)); - console.log('Publish Information'); - console.log('='.repeat(50)); - console.log('\nPublished Packages:'); - console.log(output.packages.map((p) => `${p.name} - pnpm add https://pkg.pr.new/${p.name}@${p.url.replace(/^.+@([^@]+)$/, '$1')}`).join('\n')); - if(svelte_sha){ - console.log('\nPlayground URL:'); - console.log(`\nhttps://svelte.dev/playground?version=commit-${svelte_sha}`) - } - console.log('\n' + '='.repeat(50)); - } - - if (output.event_name === 'pull_request') { - if (output.number) { - await create_or_update_comment(output.number); - } - } else if (output.event_name === 'push') { - const pull_requests = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - head: `${context.repo.owner}:${output.ref.replace('refs/heads/', '')}`, - }); - - if (pull_requests.data.length > 0) { - await create_or_update_comment(pull_requests.data[0].number); - } else { - console.log( - 'No open pull request found for this push. Logging publish information to console:' - ); - await log_publish_info(); - } - } diff --git a/.github/workflows/pkg.pr.new.yml b/.github/workflows/pkg.pr.new.yml index 252cbed769..51b7472911 100644 --- a/.github/workflows/pkg.pr.new.yml +++ b/.github/workflows/pkg.pr.new.yml @@ -1,16 +1,51 @@ -name: Publish Any Commit -on: [push, pull_request] +name: pkg.pr.new +on: + pull_request_target: + types: [opened, synchronize] + push: + branches: [main] permissions: {} jobs: - build: - permissions: {} + # This job determines the environment to use for the build job. It ensures that: + # - For pushes to main, we use the "Publish pkg.pr.new (maintainers)" environment. + # - For PRs from the same repository, we also use the "Publish pkg.pr.new (maintainers)" environment, since these are trusted. + # - For PRs from forks, we use the "Publish pkg.pr.new (external contributors)" environment, which requires manual approval by a maintainer before the build job can run. + # This protects us from running untrusted code while still allowing external contributors to use pkg.pr.new. + resolve-env: + runs-on: ubuntu-latest + outputs: + environment: ${{ steps.resolve.outputs.environment }} + steps: + - name: Determine environment + id: resolve + run: | + if [[ "${{ github.event_name }}" == "push" ]]; then + echo "environment=Publish pkg.pr.new (maintainers)" >> "$GITHUB_OUTPUT" + elif [[ "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then + echo "environment=Publish pkg.pr.new (maintainers)" >> "$GITHUB_OUTPUT" + else + echo "environment=Publish pkg.pr.new (external contributors)" >> "$GITHUB_OUTPUT" + fi + build: + needs: resolve-env runs-on: ubuntu-latest + # This is the line that ensures forks require manual approval before running the build job + environment: ${{ needs.resolve-env.outputs.environment }} + + # No permissions — this job runs user-controlled code + permissions: {} steps: - uses: actions/checkout@v6 + with: + # For pull_request_target, we must explicitly check out the PR head. + # This is safe because the environment gate above has already fired — + # an org member has approved this specific commit for external PRs. + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: @@ -24,21 +59,161 @@ jobs: run: pnpm build - run: pnpx pkg-pr-new publish --comment=off --json output.json --compact --no-template './packages/svelte' - - name: Add metadata to output + + - name: Upload output + uses: actions/upload-artifact@v4 + with: + name: output + path: ./output.json + + # Sanitizes the untrusted output from the build job before it's consumed by + # jobs with elevated permissions. This ensures that only known package names + # and valid SHA prefixes make it through. + sanitize: + needs: build + runs-on: ubuntu-latest + + permissions: {} + + steps: + - name: Download artifact + uses: actions/download-artifact@v7 + with: + name: output + + - name: Sanitize output + uses: actions/github-script@v8 + with: + script: | + const fs = require('fs'); + const raw = JSON.parse(fs.readFileSync('output.json', 'utf8')); + + const ALLOWED_PACKAGES = new Set(['svelte']); + const SHA_PATTERN = /^[0-9a-f]{6}$/; + + const packages = (raw.packages || []) + .filter(p => { + if (!ALLOWED_PACKAGES.has(p.name)) { + console.log(`Skipping unexpected package: ${JSON.stringify(p.name)}`); + return false; + } + const sha = p.url?.replace(/^.+@([^@]+)$/, '$1'); + if (!sha || !SHA_PATTERN.test(sha)) { + console.log(`Skipping package with invalid SHA: ${JSON.stringify(p.url)}`); + return false; + } + return true; + }) + .map(p => ({ + name: p.name, + sha: p.url.replace(/^.+@([^@]+)$/, '$1'), + })); + + fs.writeFileSync('sanitized-output.json', JSON.stringify({ packages }), 'utf8'); + + - name: Upload sanitized output + uses: actions/upload-artifact@v4 + with: + name: sanitized-output + path: ./sanitized-output.json + + comment: + needs: sanitize + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + + permissions: + pull-requests: write + + steps: + - name: Download sanitized artifact + uses: actions/download-artifact@v7 + with: + name: sanitized-output + + - name: Post or update comment uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const fs = require('fs'); - const output = JSON.parse(fs.readFileSync('output.json', 'utf8')); - output.number = context.issue.number; - output.event_name = context.eventName; - output.ref = context.ref; - fs.writeFileSync('output.json', JSON.stringify(output), 'utf8'); - - name: Upload output - uses: actions/upload-artifact@v6 + const { packages } = JSON.parse(fs.readFileSync('sanitized-output.json', 'utf8')); + + if (packages.length === 0) { + console.log('No valid packages found. Skipping comment.'); + return; + } + + // Issue number from trusted event context, never from the artifact + const issue_number = context.issue.number; + + const bot_comment_identifier = ``; + + const body = `${bot_comment_identifier} + + [Playground](https://svelte.dev/playground?version=pr-${issue_number}) + + \`\`\` + ${packages.map(p => `pnpm add https://pkg.pr.new/${p.name}@${issue_number}`).join('\n')} + \`\`\` + `; + + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + }); + const existing = comments.data.find(c => c.body.includes(bot_comment_identifier)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + body, + }); + } + + log: + needs: sanitize + if: github.event_name == 'push' + runs-on: ubuntu-latest + + permissions: {} + + steps: + - name: Download sanitized artifact + uses: actions/download-artifact@v7 with: - name: output - path: ./output.json + name: sanitized-output + + - name: Log publish info + uses: actions/github-script@v8 + with: + script: | + const fs = require('fs'); + const { packages } = JSON.parse(fs.readFileSync('sanitized-output.json', 'utf8')); + + if (packages.length === 0) { + console.log('No valid packages found.'); + return; + } - - run: ls -R . + console.log('\n' + '='.repeat(50)); + console.log('Publish Information'); + console.log('='.repeat(50)); + for (const p of packages) { + console.log(`${p.name} - pnpm add https://pkg.pr.new/${p.name}@${p.sha}`); + } + const svelte = packages.find(p => p.name === 'svelte'); + if (svelte) { + console.log(`\nPlayground: https://svelte.dev/playground?version=commit-${svelte.sha}`); + } + console.log('='.repeat(50));