name: PR Title Sync # WHY pull_request_target (not pull_request): the default GITHUB_TOKEN is # READ-ONLY on fork PRs under `pull_request`, so the title-sync backstop could # never `gh pr edit` a fork/agent PR. `pull_request_target` runs in the base-repo # context with a write token, which fixes fork coverage. # # WHY this is SAFE (pull_request_target is the most dangerous trigger): # - We check out the BASE repo (no `ref:`), so the only code we execute is # trusted base-repo infra (bin/gstack-pr-title-rewrite.sh). We NEVER check # out or run PR-head/fork code. # - Every attacker-controlled PR field (title, head repo, head sha) arrives via # `env:` and is referenced as a shell-quoted "$VAR". We NEVER inline a # `${{ github.event.pull_request.* }}` expression inside the run: script # (that would execute a crafted title as shell). # - The PR-head VERSION is read as DATA via the API (raw media type), from the # head repo at the head sha — never by checking out the head. # test/pr-title-sync-workflow-safety.test.ts is the static tripwire for all of # the above and fails CI if any of it regresses. on: pull_request_target: types: [opened, synchronize, edited] paths: - 'VERSION' concurrency: group: pr-title-sync-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: sync: name: Sync PR title to VERSION runs-on: ubicloud-standard-8 permissions: contents: read pull-requests: write if: github.actor != 'github-actions[bot]' steps: # Base repo only — trusted infra (the rewrite helper). No PR-head checkout. - name: Checkout base repo (trusted) uses: actions/checkout@v4 with: fetch-depth: 1 - name: Rewrite PR title to match VERSION env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUM: ${{ github.event.pull_request.number }} # Attacker-controlled on fork PRs — env-only, never inlined into run:. OLD_TITLE: ${{ github.event.pull_request.title }} BASE_REPO: ${{ github.repository }} HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail chmod +x ./bin/gstack-pr-title-rewrite.sh if [ "$HEAD_REPO" = "$BASE_REPO" ]; then IS_FORK=0; else IS_FORK=1; fi # Read the PR-head VERSION as data (raw bytes), from the head repo at # the head sha. Guard the assignment itself: under `set -e` a bare # `VERSION=$(...)` would abort the step before any later [ -z ] check. if ! VERSION=$(gh api -H "Accept: application/vnd.github.raw" \ "repos/$HEAD_REPO/contents/VERSION?ref=$HEAD_SHA" 2>/dev/null | tr -d '[:space:]'); then VERSION="" fi if [ -z "$VERSION" ]; then # Same-repo read failure should never happen — fail loudly so we # notice. A fork miss (public-contents quirk, private fork) is a # convenience gap, not a gate — warn and skip so the check stays green. if [ "$IS_FORK" = "0" ]; then echo "::error::Could not read VERSION from same-repo PR head ($HEAD_SHA)." exit 1 fi echo "::warning::Could not read VERSION from fork $HEAD_REPO ($HEAD_SHA); skipping title sync." exit 0 fi # The helper rejects a malformed VERSION (exit 2). Same policy: loud for # same-repo, soft for forks. Never echo the raw (attacker-controlled) # title — Actions still parses ::workflow-command:: from stdout. if ! NEW_TITLE=$(./bin/gstack-pr-title-rewrite.sh "$VERSION" "$OLD_TITLE"); then if [ "$IS_FORK" = "0" ]; then echo "::error::Could not compute title for VERSION '$VERSION' on PR #$PR_NUM." exit 1 fi echo "::warning::Could not compute title for fork PR #$PR_NUM; skipping." exit 0 fi if [ "$NEW_TITLE" = "$OLD_TITLE" ]; then echo "PR #$PR_NUM title already correct; no change." exit 0 fi gh pr edit "$PR_NUM" --title "$NEW_TITLE" echo "PR #$PR_NUM title synced to VERSION."