diff --git a/.github/actions/app-token/action.yml b/.github/actions/app-token/action.yml new file mode 100644 index 000000000..c811148ba --- /dev/null +++ b/.github/actions/app-token/action.yml @@ -0,0 +1,59 @@ +name: Get app token +description: > + Mint a short-lived installation token for the docs automation GitHub App and + resolve the git identity of its bot user. + +inputs: + app-id: + description: App ID or client ID of the docs automation app. + required: true + private-key: + description: Private key of the docs automation app. + required: true + +outputs: + token: + description: Installation token, scoped to this repository and valid for one hour. + value: ${{ steps.app-token.outputs.token }} + user-name: + description: Git user name of the app's bot user. + value: ${{ steps.bot-user.outputs.user-name }} + user-email: + description: Git email of the app's bot user, which links commits to the bot account. + value: ${{ steps.bot-user.outputs.user-email }} + committer: + description: Bot identity as a single `Name ` string, the form create-pull-request expects. + value: ${{ steps.bot-user.outputs.committer }} + +runs: + using: composite + steps: + - name: Mint an installation token + id: app-token + uses: actions/create-github-app-token@v2 + with: + # `app-id` takes either the numeric app ID or the app's client ID. + app-id: ${{ inputs.app-id }} + private-key: ${{ inputs.private-key }} + + - name: Resolve the bot user identity + id: bot-user + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + run: | + # Assign before echoing. `echo "id=$(gh api ...)"` reports echo's exit + # code, so a failed lookup would pass the step with an empty id, and + # the resulting address is one git accepts but GitHub cannot link to + # the bot — misattributed commits, no error anywhere. + id=$(gh api "/users/${APP_SLUG}[bot]" --jq .id) + + name="${APP_SLUG}[bot]" + email="${id}+${APP_SLUG}[bot]@users.noreply.github.com" + + { + echo "user-name=${name}" + echo "user-email=${email}" + echo "committer=${name} <${email}>" + } >>"$GITHUB_OUTPUT" diff --git a/.github/workflows/generate-toolkit-docs.yml b/.github/workflows/generate-toolkit-docs.yml index 6e8b63e31..f7fa9cb94 100644 --- a/.github/workflows/generate-toolkit-docs.yml +++ b/.github/workflows/generate-toolkit-docs.yml @@ -97,13 +97,24 @@ jobs: - name: Sync toolkit sidebar navigation run: pnpm exec tsx toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts --remove-empty-sections=false --verbose + # GITHUB_TOKEN PRs do not start other workflows. Docs Bot is a GitHub + # App, so the opened automation PR still runs Test. + - name: Get app token + id: app-token + uses: ./.github/actions/app-token + with: + app-id: ${{ secrets.DOCS_BOT_CLIENT_ID }} + private-key: ${{ secrets.DOCS_BOT_PRIVATE_KEY }} + - name: Create pull request id: cpr uses: peter-evans/create-pull-request@v7 env: HUSKY: 0 with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.app-token.outputs.token }} + author: ${{ steps.app-token.outputs.committer }} + committer: ${{ steps.app-token.outputs.committer }} commit-message: "[AUTO] Adding MCP Servers docs update" title: "[AUTO] Adding MCP Servers docs update" body: | @@ -130,7 +141,7 @@ jobs: echo "::warning::Could not request review on PR #${{ steps.cpr.outputs.pull-request-number }}: $(cat review-error.log)" fi env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} - name: Upload generation report if: always() diff --git a/.github/workflows/llmstxt.yml b/.github/workflows/llmstxt.yml index cf33d7237..09d0f3a2f 100644 --- a/.github/workflows/llmstxt.yml +++ b/.github/workflows/llmstxt.yml @@ -37,7 +37,10 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.ref || github.ref }} - token: ${{ secrets.GITHUB_TOKEN }} + # No credentials to persist: the app token is minted further down, + # once there is something to push. The commit step below supplies it + # to git explicitly. + persist-credentials: false - name: Install dependencies run: npm install -g pnpm @@ -59,21 +62,38 @@ jobs: echo "has_changes=false" >> $GITHUB_OUTPUT fi + # GITHUB_TOKEN pushes and PRs do not start other workflows. Docs Bot + # is a GitHub App, so Test still runs on the new SHA. + - name: Get app token + if: steps.check-changes.outputs.has_changes == 'true' + id: app-token + uses: ./.github/actions/app-token + with: + app-id: ${{ secrets.DOCS_BOT_CLIENT_ID }} + private-key: ${{ secrets.DOCS_BOT_PRIVATE_KEY }} + - name: Commit changes to PR if: steps.check-changes.outputs.has_changes == 'true' && github.event_name == 'pull_request' + env: + GH_APP_TOKEN: ${{ steps.app-token.outputs.token }} + USER_NAME: ${{ steps.app-token.outputs.user-name }} + USER_EMAIL: ${{ steps.app-token.outputs.user-email }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "$USER_NAME" + git config user.email "$USER_EMAIL" git add public/llms.txt git commit -m "🤖 Regenerate LLMs.txt" - git push + git push "https://x-access-token:${GH_APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${HEAD_REF}" - name: Create Pull Request (for scheduled/manual runs) if: steps.check-changes.outputs.has_changes == 'true' && github.event_name != 'pull_request' id: cpr uses: peter-evans/create-pull-request@v7 with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.app-token.outputs.token }} + author: ${{ steps.app-token.outputs.committer }} + committer: ${{ steps.app-token.outputs.committer }} commit-message: Regenerate LLMs.txt and related files branch: auto-update-llms-txt delete-branch: true @@ -87,11 +107,11 @@ jobs: continue-on-error: true run: gh pr edit ${{ steps.cpr.outputs.pull-request-number }} --add-reviewer ArcadeAI/engineering-tools-and-dx env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} - name: Enable Pull Request Automerge if: steps.check-changes.outputs.has_changes == 'true' && github.event_name != 'pull_request' && steps.cpr.outputs.pull-request-number != '' continue-on-error: true run: gh pr merge --squash --auto ${{ steps.cpr.outputs.pull-request-number }} env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/update-design-system-dependency.yml b/.github/workflows/update-design-system-dependency.yml index 756bd95cc..4f1287013 100644 --- a/.github/workflows/update-design-system-dependency.yml +++ b/.github/workflows/update-design-system-dependency.yml @@ -47,6 +47,16 @@ jobs: pnpm vitest run toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts toolkit-docs-generator/tests/sources/oauth-provider-resolver.test.ts pnpm run build + # GITHUB_TOKEN PRs do not start other workflows. Docs Bot is a GitHub + # App, so the opened automation PR still runs Test. + - name: Get app token + if: steps.check-changes.outputs.has_changes == 'true' + id: app-token + uses: ./.github/actions/app-token + with: + app-id: ${{ secrets.DOCS_BOT_CLIENT_ID }} + private-key: ${{ secrets.DOCS_BOT_PRIVATE_KEY }} + - name: Create pull request if: steps.check-changes.outputs.has_changes == 'true' id: cpr @@ -55,7 +65,9 @@ jobs: HUSKY: 0 SKIP_HUSKY: 1 with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.app-token.outputs.token }} + author: ${{ steps.app-token.outputs.committer }} + committer: ${{ steps.app-token.outputs.committer }} commit-message: "chore: update @arcadeai/design-system to latest" title: "chore: update @arcadeai/design-system to latest" body: | @@ -77,4 +89,4 @@ jobs: continue-on-error: true run: gh pr edit ${{ steps.cpr.outputs.pull-request-number }} --add-reviewer ArcadeAI/engineering-tools-and-dx env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/tests/docs-automation-tokens.test.ts b/tests/docs-automation-tokens.test.ts new file mode 100644 index 000000000..b109f6c26 --- /dev/null +++ b/tests/docs-automation-tokens.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "vitest"; + +const readWorkflow = (name: string) => + readFileSync(join(process.cwd(), ".github", "workflows", name), "utf-8"); + +const AUTOMATION_WORKFLOWS = [ + "generate-toolkit-docs.yml", + "llmstxt.yml", + "update-design-system-dependency.yml", +] as const; + +for (const name of AUTOMATION_WORKFLOWS) { + test(`${name} authenticates PR writes with Docs Bot, not GITHUB_TOKEN`, () => { + const yaml = readWorkflow(name); + + expect(yaml).toContain("uses: ./.github/actions/app-token"); + expect(yaml).toContain("secrets.DOCS_BOT_CLIENT_ID"); + expect(yaml).toContain("secrets.DOCS_BOT_PRIVATE_KEY"); + expect(yaml).toContain("steps.app-token.outputs.token"); + expect(yaml).not.toContain("secrets.GITHUB_TOKEN"); + }); +} + +test("app-token composite action mints a GitHub App installation token", () => { + const action = readFileSync( + join(process.cwd(), ".github", "actions", "app-token", "action.yml"), + "utf-8" + ); + + expect(action).toContain("actions/create-github-app-token@v2"); +}); diff --git a/toolkit-docs-generator/tests/workflows/generate-toolkit-docs.test.ts b/toolkit-docs-generator/tests/workflows/generate-toolkit-docs.test.ts index 731ed2d93..702484308 100644 --- a/toolkit-docs-generator/tests/workflows/generate-toolkit-docs.test.ts +++ b/toolkit-docs-generator/tests/workflows/generate-toolkit-docs.test.ts @@ -48,6 +48,11 @@ test("porter workflow generates docs and opens a PR", () => { expect(workflowContents).toContain("HUSKY: 0"); expect(workflowContents).toContain("[AUTO] Adding MCP Servers docs update"); expect(workflowContents).toContain("pull-requests: write"); + // GITHUB_TOKEN PRs do not start other workflows, so Test would never run + // on the automation PR. Docs Bot is a GitHub App and avoids that restriction. + expect(workflowContents).toContain("uses: ./.github/actions/app-token"); + expect(workflowContents).toContain("steps.app-token.outputs.token"); + expect(workflowContents).not.toContain("secrets.GITHUB_TOKEN"); }); test("porter workflow does not build the docs generator before running it", () => {