The GitHub Actions Permission Nightmare: A Journey Through Documentation Hell

Or: How I Spent Hours Building a Sophisticated Solution to Automate Clicking a Button (Spoiler: It Still Doesn’t Work)

The Innocent Beginning

It started with such a simple request: “Help me auto-sync my fork with its upstream.” How hard could it be? Just fetch upstream changes, create a branch, push it, and open a PR. Five minutes, tops.

Narrator: It was not five minutes.

Act I: The Schema Validation Betrayal

I confidently started with what seemed like reasonable permissions:

permissions:
  contents: write      # push the sync branch
  pull-requests: write # open, approve & merge the PR
  workflows: write     # enable auto-merge

GitHub’s schema validator immediately slapped me with:

Schema validation: Property 'workflows' is not allowed

“Ah,” I thought, “clearly workflows isn’t a valid permission. Let me just remove that invalid line.”

Famous last words.

Act II: The Runtime Reality Check

With the “invalid” permission removed, the workflow passed validation but failed spectacularly at runtime:

! [remote rejected] upstream-sync -> upstream-sync 
(refusing to allow a GitHub App to create or update workflow 
`.github/workflows/main.yml` without `workflows` permission)

Wait, WHAT? The error message is literally asking for the workflows permission that the schema validator just told me doesn’t exist.

This is like being told you need a driver’s license to drive, but also being told that driver’s licenses are a myth.

Act III: The Documentation Rabbit Hole

Surely GitHub’s documentation would clear this up. I dove into their official docs, searching for the definitive list of valid permissions. What I found was:

  • 🔍 Multiple documentation pages with different information
  • 📝 Some pages mentioning actions: write for workflow-related operations
  • 📋 Other pages showing examples with permissions that don’t match the schema
  • 🤷 Zero consistency between schema validation and runtime behavior

I tried actions: write instead. Same error. The runtime system was still demanding the mythical workflows permission like a bouncer asking for a membership card to a club that doesn’t exist.

Act IV: The Catch-22

At this point, I was trapped in a perfect catch-22:

  • Schema validation: “You cannot use workflows: write - it doesn’t exist!”
  • Runtime system: “You must use workflows permission to modify workflow files!”
  • Documentation: “¯\(ツ)/¯”

I tried adding workflows: write back, thinking maybe the schema validator was wrong. Result: Back to the original schema validation error.

It’s like GitHub’s left hand doesn’t know what its right hand is doing, and both hands are actively fighting each other while flipping you off.

Act V: The CLI Comedy of Errors

“Fine,” I said, “I’ll use GitHub CLI instead of those problematic actions.”

First attempt:

gh pr create --json number
unknown flag: --json

The GitHub Actions runner had an older version of gh that didn’t support modern flags. Because of course it did.

Second attempt (simplified):

gh pr create --head upstream-sync --base master
pull request create failed: GraphQL: Resource not accessible by integration (createPullRequest)

Apparently, the GITHUB_TOKEN doesn’t have GraphQL permissions for createPullRequest. Because why would the token designed for GitHub Actions be able to… create pull requests in GitHub Actions?

Act VI: The API Expedition

“Screw it,” I declared, “I’ll use the REST API directly!”

curl -X POST "https://api.github.com/repos/user/repo/pulls" \
  -d '{"title": "Automated upstream merge", "head": "upstream-sync", "base": "master"}'

Response:

{
  "message": "GitHub Actions is not permitted to create or approve pull requests.",
  "status": "403"
}

Ah, the plot thickens! There’s a repository-level setting called “Allow GitHub Actions to create and approve pull requests” that was disabled. This is a security setting that prevents automated PR creation.

After enabling this setting, the API finally worked! 🎉

Act VII: The Self-Approval Scandal

Success! The PR was created automatically. Now for the auto-approval…

{
  "message": "Unprocessable Entity",
  "errors": ["Can not approve your own pull request"],
  "status": "422"
}

Of course. GitHub doesn’t allow you to approve your own PRs. This is actually good security practice, but it means true automation is impossible.

Act VIII: The Branch Protection Plot Twist

“Fine,” I said, “I’ll just merge the changes directly to master and skip the PR entirely!”

git push origin master
! [remote rejected] master -> master (protected branch hook declined)

Organization branch protection rules prevent direct pushes to master. Because we’re in an enterprise environment where security policies actually matter.

The Bitter End: A Sophisticated Solution to Nothing

After hours of troubleshooting through GitHub’s permission maze, I had built a technically perfect workflow that:

Successfully syncs upstream changes while preserving local workflow files
Automatically creates well-formatted PRs with clear descriptions
Enables auto-merge so PRs merge immediately after approval
Handles edge cases and provides comprehensive error handling
Still requires manual approval due to organizational branch protection rules
Provides zero practical benefit over clicking “Sync fork” in the GitHub UI

The Lessons Learned

  1. GitHub’s tooling is inconsistent: Schema validation and runtime behavior can directly contradict each other.

  2. Error messages lie: When GitHub says you need “workflows permission,” it might mean something that doesn’t actually exist in the schema.

  3. Documentation is unreliable: Multiple official sources can give conflicting information about the same feature.

  4. Enterprise constraints trump clever solutions: No amount of technical sophistication can bypass organizational security policies.

  5. Sometimes the simple solution is the right solution: Clicking a button in the UI might actually be more efficient than building a complex automation that doesn’t automate the painful parts.

  6. Policy problems require policy solutions: The real blocker wasn’t technical - it was organizational rules that prevent true automation.

The Final Irony

The original problem was having to manually sync multiple forks daily by clicking through the GitHub UI. After hours of sophisticated engineering, I built a system that… still requires manually clicking through GitHub to approve PRs.

In the end, I automated everything except the part that actually needed automation.

The Real Takeaway

This experience perfectly illustrates the difference between technical possibility and practical value. Just because you can build something doesn’t mean you should. Sometimes the boring, manual solution is actually the right one - especially when organizational constraints make true automation impossible.

The GitHub “Sync fork” button exists for a reason. It’s simple, reliable, and achieves the same end result with the same amount of manual intervention as our sophisticated workflow.

Sometimes the real automation is the clicking I did along the way. 🤷‍♂️


P.S. - If you’re facing similar GitHub Actions permission issues, save yourself some time: check your repository settings first, accept that organizational policies exist for a reason, and don’t be afraid to embrace the simple solution. Your sanity will thank you.

Technical Appendix

For those brave souls who want to see the final working (but practically useless) workflow:

name: Upstream-sync → protected master
on:
  schedule:            # run every night
    - cron:  '7 2 * * *'
  workflow_dispatch:   # (optional) manual trigger

permissions:           # minimum perms the job needs
  contents: write      # push the sync branch
  pull-requests: write # open, approve & merge the PR

concurrency:           # never let two syncs race
  group: $-$
  cancel-in-progress: true

jobs:
  sync:
    runs-on: ubuntu-latest

    steps:
      # 1. full clone so we always have the latest tip
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          token: $

      # 2. fetch upstream & copy it to a side branch
      - name: Update upstream-sync branch
        env:
          GITHUB_TOKEN: $
        run: |
          # Configure git identity
          git config --global user.email "action@github.com"
          git config --global user.name "GitHub Action"
          
          git remote add upstream https://github.com/openjdk/jdk.git
          git fetch upstream master
          
          # Create sync branch from current master to preserve workflows
          git checkout -B upstream-sync origin/master
          
          # Simple merge approach - let's see what happens
          if git merge upstream/master --no-edit --allow-unrelated-histories; then
            echo "=== Merge successful ==="
            git log --oneline -5
          else
            echo "=== Merge failed, trying alternative approach ==="
            git merge --abort || true
            git reset --hard upstream/master
            # Restore our workflow files after taking upstream
            git checkout origin/master -- .github/workflows/
            git add .github/workflows/
            git commit -m "Preserve local workflow files during upstream sync"
            echo "=== Alternative approach completed ==="
            git log --oneline -5
          fi
          
          git push -f origin upstream-sync

      # 3. Create PR and attempt auto-merge (constrained by org branch protection)
      - name: Create PR for upstream sync
        env:
          GITHUB_TOKEN: $
        run: |
          # Check if PR already exists
          PR_EXISTS=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
            "https://api.github.com/repos/$/pulls?head=$:upstream-sync&base=master" \
            | jq -r '.[0].number // empty')
          
          if [ -n "$PR_EXISTS" ]; then
            echo "PR #$PR_EXISTS already exists - updating it"
            curl -s -X PATCH -H "Authorization: token $GITHUB_TOKEN" \
              -H "Accept: application/vnd.github.v3+json" \
              "https://api.github.com/repos/$/pulls/$PR_EXISTS" \
              -d '{
                "title": "🤖 Automated upstream sync",
                "body": "**Automated nightly sync of upstream changes**\n\n📊 **What this includes:**\n- Latest commits from `openjdk/jdk17u-dev:master`\n- Preserves local workflow configurations\n- Ready for immediate merge\n\n🔄 **Auto-generated by:** `.github/workflows/dd-sync.yml`\n\n⚡ **Action required:** Just click **Approve** to merge automatically!"
              }'
            echo "✅ Updated existing PR #$PR_EXISTS"
          else
            echo "Creating new PR for upstream sync"
            PR_RESPONSE=$(curl -s -X POST -H "Authorization: token $GITHUB_TOKEN" \
              -H "Accept: application/vnd.github.v3+json" \
              "https://api.github.com/repos/$/pulls" \
              -d '{
                "title": "🤖 Automated upstream sync",
                "body": "**Automated nightly sync of upstream changes**\n\n📊 **What this includes:**\n- Latest commits from `openjdk/jdk17u-dev:master`\n- Preserves local workflow configurations\n- Ready for immediate merge\n\n🔄 **Auto-generated by:** `.github/workflows/dd-sync.yml`\n\n⚡ **Action required:** Just click **Approve** to merge automatically!",
                "head": "upstream-sync",
                "base": "master"
              }')
            PR_NUMBER=$(echo "$PR_RESPONSE" | jq -r '.number')
            if [ "$PR_NUMBER" != "null" ] && [ -n "$PR_NUMBER" ]; then
              echo "✅ Created PR #$PR_NUMBER"
              
              # Enable auto-merge immediately
              curl -s -X PUT -H "Authorization: token $GITHUB_TOKEN" \
                -H "Accept: application/vnd.github+json" \
                "https://api.github.com/repos/$/pulls/$PR_NUMBER/merge" \
                -d '{"merge_method": "merge"}' && echo "🚀 Auto-merge enabled" || echo "⚠️ Auto-merge requires approval"
            else
              echo "❌ Failed to create PR: $PR_RESPONSE"
            fi
          fi
          
          echo ""
          echo "🎯 **SUMMARY:** Upstream sync PR is ready!"
          echo "📝 **Next step:** Approve the PR and it will merge automatically"
          echo "🔗 **Link:** https://github.com/$/pulls"

This workflow technically works perfectly. It just doesn’t solve the actual problem. 🎭