github-action icon indicating copy to clipboard operation
github-action copied to clipboard

Support GitHub Apps for Authentication in Crowdin GitHub Action

Open Paul-Weaver opened this issue 11 months ago • 16 comments

Feature Request: Support GitHub Apps for Authentication in Crowdin GitHub Action

Is your feature request related to a problem? Please describe.

Currently, the Crowdin GitHub Action requires a Personal Access Token (PAT) for authentication, which introduces security risks and compliance challenges. Many organizations, including those using GitHub Enterprise Managed Users (EMU), are transitioning away from PATs in favor of GitHub Apps, which provide:

  • Scoped permissions to minimize security risks.
  • Automatic token rotation to reduce manual management.
  • Better compliance with enterprise security policies.

Without GitHub Apps support, teams are forced to manage long-lived PATs, increasing the risk of token leaks, adding operational overhead, and potentially preventing adoption in environments with strict security policies.

This is critical for us due to security concerns, and we may be unable to continue using the Crowdin GitHub Action if GitHub Apps authentication is not supported.

Describe the solution you’d like

We request official support for GitHub Apps authentication in Crowdin GitHub Action, allowing:

  • Authentication via GitHub App tokens instead of manually managed PATs.
  • Support for GITHUB_TOKEN in workflows, leveraging GitHub’s built-in authentication with appropriate permissions.
  • A migration path from PAT-based authentication to GitHub Apps for existing users.

Describe alternatives you’ve considered

  • Continuing with PATs: Not ideal due to security risks, manual token rotation, and GitHub's push towards deprecating PAT usage in automation.

Additional context

Crowdin’s official GitHub Action (crowdin/github-action) currently requires PATs for pushing translations and creating pull requests.

  • GitHub officially recommends GitHub Apps for automation and has enabled GITHUB_TOKEN authentication for repository access.
  • Many organizations, including ours, will be unable to use Crowdin GitHub Action if GitHub Apps authentication is not supported.

This feature is critical for us, and we’d love to hear if it's on the roadmap. We're happy to assist with testing to ensure a smooth transition.

Looking forward to your response. Thanks!

Paul-Weaver avatar Mar 05 '25 15:03 Paul-Weaver

Hi @Paul-Weaver, thanks for the request!

There was a similar request in issue #264. Could you please check if the solution suggested in that issue works for you?

andrii-bodnar avatar Mar 05 '25 15:03 andrii-bodnar

Hi @Paul-Weaver, thanks for the request!

There was a similar request in issue #264. Could you please check if the solution suggested in that issue works for you?

Thanks for the swift response, I will create GitHub App then test it

Paul-Weaver avatar Mar 05 '25 15:03 Paul-Weaver

@andrii-bodnar it actually works on latest crowdin using GITHUB_TOKEN and having the app token there. Or am I misunderstanding something?

name: crowdin-test-run

on:
  workflow_dispatch:

jobs:
  crowdin-test:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write

    outputs:
      pull_request_number: ${{ steps.download_translations.outputs.pull_request_number }}

    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Generate GitHub App Token (Crowdin Test)
        id: generate-token
        uses: actions/create-github-app-token@v1
        with:
          app-id: ${{ vars.CROWDIN_APP_ID }}
          private-key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}

      - id: download_translations
        name: Download Translations & Create PR (Crowdin Test)
        uses: crowdin/github-action@v2
        with:
          upload_sources: true
          download_translations: true
          upload_translations: false
          download_translations_args: >
            --language=de
          create_pull_request: true
        env:
          GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
          CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
          CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

Paul-Weaver avatar Mar 06 '25 10:03 Paul-Weaver

@Paul-Weaver it's interesting 🤔 Based on the mentioned issue, I thought it was not working this way

andrii-bodnar avatar Mar 06 '25 12:03 andrii-bodnar

@Paul-Weaver it's interesting 🤔 Based on the mentioned issue, I thought it was not working this way

I'm not sure, but you can see they don't have the correct permissions anyhow it's missing pull-requests: write

Paul-Weaver avatar Mar 06 '25 12:03 Paul-Weaver

@Paul-Weaver indeed, that looks like a reason. So it seems to work without any changes to the action code.

By the way, would you like to contribute a readme update describing how to authenticate via the GitHub app? It would be very helpful for other developers.

andrii-bodnar avatar Mar 06 '25 12:03 andrii-bodnar

@Paul-Weaver indeed, that looks like a reason. So it seems to work without any changes to the action code.

By the way, would you like to contribute a readme update describing how to authenticate via the GitHub app? It would be very helpful for other developers.

Sure I can help with that!

Paul-Weaver avatar Mar 06 '25 23:03 Paul-Weaver

@Paul-Weaver Did you happen to have a chance to write such docs? I'm struggling to understand why this doesn't work:

jobs:
  crowdin-test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: read

Here we see the job has readonly access.

But suppose I supply a GitHub App access token that I am absolutely certain has contents: write and pull-requests: write on the relevant repo (assume I obtain one and store it in steps.generate-token.outputs.token):

- name: Crowdin
  uses: crowdin/github-action@b8012bd5491b8aa8578b73ab5b5f5e7c94aaa6e2
  with: [snip]
  env:
    GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
    CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
    CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

I get a 403 on trying to write to the repo.

My suspicion is that one cannot overwrite GITHUB_TOKEN like that, which @lol3909 noted here. I note that in your (Paul's) comment you have write access in the permissions block and I wonder if that's why it worked for you there?

@andrii-bodnar Is there any possibility of being able to supply GITHUB_TOKEN as GH_TOKEN in the case where we want to override it in our jobs? That might achieve my desired behaviour, which is:

  • Start a job with very low permissions (readonly)
  • Make a call to obtain a write-access token
  • Pass that token to Crowdin (i.e. a "stepped-up" GITHUB_TOKEN)

3dbrows avatar May 28 '25 12:05 3dbrows

Hi @3dbrows, In the related issue, there was a proof of concept for this. Could you please check to see if that solution works for you?

andrii-bodnar avatar Jun 02 '25 07:06 andrii-bodnar

Thanks very much @andrii-bodnar , I will try that and report back; I am wondering if this will work in light of what GitHub say here about restrictions on variable names:

You can't overwrite the value of the default environment variables named GITHUB_* and RUNNER_*.

It's not super clear to me from that whether overwrite is used in a strict sense, or it means you also can't write them with original values.

So, it might object to GITHUB_APP_TOKEN but not to GH_TOKEN or similar, because the latter doesn't match GITHUB_*. We'll see!

3dbrows avatar Jun 04 '25 11:06 3dbrows

@3dbrows I just merged your PR into the app_token branch. Could you please check it out?

andrii-bodnar avatar Jun 04 '25 13:06 andrii-bodnar

I've been struggling with this but I don't think it's any problem with the action; more what limitations GitHub Actions puts in place.

I tried with the following, but it 403's every time. Interestingly, if I get a token locally and try pushing to a REPO_URL containing github.com rather than api.github.com, it works. I also tried using different GITHUB_ACTOR values in the REPO_URL, but it made no difference.

I'm left with the suspicion that GitHub Actions does not let you escalate privileges by generating a token with broader permissions than what is given in the job's permissions stanza... but I also have big doubts about that, because how would the remote Git origin know?

And yet, locally, using git push https://any-username:[email protected]/foo/bar.git does work. Just not in an Action. (Using api.github.com works in neither place.) For what it's worth, my token starts with ghs_ and I'm on GitHub Enterprise SaaS.

At this point, I'm sorry to say, I'm out of ideas... so we might be forced to just use contents: write in the job permissions.

      - uses: actions/[email protected]
        id: generate-token
        with:
          app-id: "<redacted>"
          private-key: ${{ secrets.APP_PRIVATE_KEY }}
          permission-contents: write
      - name: crowdin action
        uses: crowdin/github-action@app_token
        with:
          config: crowdin.yml
          github_api_base_url: github.com  # I also tried omitting this line
          upload_sources: false
          download_translations: true
        env:
          GH_TOKEN: ${{ steps.generate-token.outputs.token }}
          CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
          CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

Thanks for looking at this with me!

3dbrows avatar Jun 06 '25 16:06 3dbrows

@3dbrows here’s what worked for me:

  1. Two tokens, two roles
  • GITHUB_TOKEN (default) handles the REST / GraphQL API calls (open/update PR, set commit status).
  • GH_TOKEN (GitHub App token) handles the raw git push. If GITHUB_TOKEN (default token is read-only), the API call that opens the PR will 403—even though the push itself succeeded.
  1. Give the job write permissions
    Add “contents: write” and “pull-requests: write” to the job.
    This upgrades the default token just long enough for the API work while GH_TOKEN stays scoped for the push.

  2. Drop github_api_base_url : Let the action use its default endpoint (api.github.com) unless you’re on a self-hosted GHES instance.

Fix

Give the job write perms so the default token can open/update the PR:

name: crowdin-sync

on:
  workflow_dispatch:

jobs:
  crowdin:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write

    steps:
      - uses: actions/checkout@v4

      - uses: actions/create-github-app-token@v2
        id: app-token
        with:
          app-id: ${{ vars.CROWDIN_APP_ID }}
          private-key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}
          permission-contents: write
          permission-pull-requests: write   # keeps token scoped

      - name: Crowdin sync
        uses: crowdin/github-action@app_token
        with:
          config: crowdin.yml
          upload_sources: false
          download_translations: true
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
          CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

Give that a spin and shout if anything’s still off.

Paul-Weaver avatar Jun 06 '25 16:06 Paul-Weaver

TL;DR — The Crowdin action needs two tokens: the default GITHUB_TOKEN for API calls (opening/updating the PR) and a scoped GH_TOKEN from your GitHub App for the actual git push. If the job’s permissions are read-only, those API calls 403 even though the push works. Give the job temporary contents: write and pull-requests: write perms so GITHUB_TOKEN can finish its work, keep GH_TOKEN for the push, and drop github_api_base_url unless you’re on GHES. That combo removes the 403.

Paul-Weaver avatar Jun 06 '25 16:06 Paul-Weaver

Thanks Paul. That does, indeed, work. I'd conclude from this that if the action were to use GH_TOKEN for everything, (though I thought it did) obtained from a correctly configured app (with permissions to write PRs and pushes), then the following would be possible.

The main (only?) advantage being that it scores maximum points in terms of token scope reduction.

name: crowdin-sync

on:
  workflow_dispatch:

jobs:
  crowdin:
    runs-on: ubuntu-latest
    permissions:
      contents: read # to allow the checkout to work

    steps:
      - uses: actions/checkout@v4

      - uses: actions/create-github-app-token@v2
        id: app-token
        with:
          app-id: ${{ vars.CROWDIN_APP_ID }}
          private-key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}
          permission-contents: write
          permission-pull-requests: write

      - name: Crowdin sync
        uses: crowdin/github-action@app_token
        with:
          config: crowdin.yml
          upload_sources: false
          download_translations: true
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
          CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

3dbrows avatar Jun 09 '25 07:06 3dbrows

Great work on these findings, @Paul-Weaver and @3dbrows. Thank you! 🚀

andrii-bodnar avatar Jun 09 '25 09:06 andrii-bodnar