diff --git a/.funcignore b/.funcignore index 8d5be39..0decfbc 100644 --- a/.funcignore +++ b/.funcignore @@ -2,3 +2,6 @@ /*.md /host.json /local.settings.json +/package.json +/package-lock.json +/jest.config.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 27d55e7..d3c728f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,22 +1,61 @@ name: Deploy to Azure on: + workflow_dispatch: push: branches: - main paths: + - '.funcignore' - '.github/workflows/deploy.yml' - 'GitGitGadget/**' +permissions: + contents: read + id-token: write + jobs: deploy: - if: github.event.repository.fork == false + if: github.event.repository.fork == false || vars.DEPLOY_WITH_WORKFLOWS != '' environment: deploy-to-azure runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + - name: parse DEPLOY_WITH_WORKFLOWS + if: vars.DEPLOY_WITH_WORKFLOWS != '' && contains(vars.DEPLOY_WITH_WORKFLOWS, '/') + id: parsed + env: + WORKFLOWS_REPO: '${{ vars.DEPLOY_WITH_WORKFLOWS }}' + run: | + echo "owner=${WORKFLOWS_REPO%%/*}" >>$GITHUB_OUTPUT && + echo "name=${WORKFLOWS_REPO#*/}" >>$GITHUB_OUTPUT + - name: retrieve `vars.CONFIG` from workflows repo + if: vars.DEPLOY_WITH_WORKFLOWS != '' + env: + WORKFLOWS_REPO: '${{ vars.DEPLOY_WITH_WORKFLOWS }}' + GH_TOKEN: ${{ steps.workflows-repo-token.outputs.token || secrets.GITHUB_TOKEN }} + run: | + set -x && + if ! curl -fLO https://github.com/"$WORKFLOWS_REPO"/raw/config/gitgitgadget-config.json + then + echo "::error::Could not retrieve 'gitgitgadget-config.json' from the 'config' branch of $WORKFLOWS_REPO" + exit 1 + fi && + jq '. + { + "workflowsRepo": { + "owner": "${{ steps.parsed.outputs.owner }}", + "name": "${{ steps.parsed.outputs.name }}" + } + }' GitGitGadget/gitgitgadget-config.json && + echo "Using the following configuration:" && + cat GitGitGadget/gitgitgadget-config.json + - name: 'Login via Azure CLI' + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - uses: Azure/functions-action@v1 with: - app-name: GitGitGadget - publish-profile: ${{ secrets.AZURE_FUNCTIONAPP_PUBLISH_PROFILE }} + app-name: ${{ secrets.AZURE_FUNCTION_NAME || 'GitGitGadget' }} respect-funcignore: true diff --git a/GitGitGadget/gitgitgadget-config.json b/GitGitGadget/gitgitgadget-config.json new file mode 100644 index 0000000..3104f87 --- /dev/null +++ b/GitGitGadget/gitgitgadget-config.json @@ -0,0 +1,78 @@ +{ + "repo": { + "name": "git", + "owner": "gitgitgadget", + "baseOwner": "git", + "testOwner": "dscho", + "owners": [ + "gitgitgadget", + "git", + "dscho" + ], + "branches": [ + "maint", + "seen" + ], + "closingBranches": [ + "maint", + "master" + ], + "trackingBranches": [ + "maint", + "seen", + "master", + "next" + ], + "maintainerBranch": "gitster", + "host": "github.com" + }, + "mailrepo": { + "name": "git", + "owner": "gitgitgadget", + "branch": "master", + "host": "lore.kernel.org", + "url": "https://lore.kernel.org/git/", + "public_inbox_epoch": 1, + "mirrorURL": "https://github.com/gitgitgadget/git-mailing-list-mirror", + "mirrorRef": "refs/heads/lore-1", + "descriptiveName": "lore.kernel/git" + }, + "mail": { + "author": "GitGitGadget", + "sender": "GitGitGadget", + "smtpUser": "gitgitgadget@gmail.com", + "smtpHost": "smtp.gmail.com" + }, + "app": { + "appID": 12836, + "installationID": 195971, + "name": "gitgitgadget", + "displayName": "GitGitGadget", + "altname": "gitgitgadget-git" + }, + "lint": { + "maxCommitsIgnore": [ + "https://github.com/gitgitgadget/git/pull/923" + ], + "maxCommits": 30 + }, + "user": { + "allowUserAsLogin": false + }, + "syncUpstreamBranches": [ + { + "sourceRepo": "gitster/git", + "targetRepo": "gitgitgadget/git", + "sourceRefRegex": "^refs/heads/(maint-\\d|[a-z][a-z]/)" + }, + { + "sourceRepo": "j6t/git-gui", + "targetRepo": "gitgitgadget/git", + "targetRefNamespace": "git-gui/" + } + ], + "workflowsRepo": { + "owner": "gitgitgadget-workflows", + "name": "gitgitgadget-workflows" + } +} diff --git a/GitGitGadget/index.js b/GitGitGadget/index.js index 17623e0..e2cc542 100644 --- a/GitGitGadget/index.js +++ b/GitGitGadget/index.js @@ -25,14 +25,10 @@ module.exports = async (context, req) => { } try { - /* - * For various reasons, the GitGitGadget GitHub App can be installed - * on any random repository. However, GitGitGadget only wants to support - * the `gitgitgadget/git` and the `git/git` repository (with the - * `dscho/git` one thrown in for debugging purposes). - */ - const orgs = ['gitgitgadget', 'git', 'dscho'] - const a = [context, undefined, 'gitgitgadget-workflows', 'gitgitgadget-workflows'] + const { readFileSync } = require('fs') + const config = JSON.parse(readFileSync(`${__dirname}/gitgitgadget-config.json`)) + const orgs = config.repo.owners + const a = [context, undefined, config.workflowsRepo.owner, config.workflowsRepo.name] const eventType = context.req.headers['x-github-event']; context.log(`Got eventType: ${eventType}`); @@ -40,7 +36,7 @@ module.exports = async (context, req) => { if (!orgs.includes(repositoryOwner)) { context.res = { status: 403, - body: 'Refusing to work on a repository other than gitgitgadget/git or git/git' + body: `Refusing to work on any repository outside of ${orgs.join(', ')}`, }; } else if (eventType === 'pull_request') { if (req.body.action !== 'opened' && req.body.action !== 'synchronize') { @@ -58,9 +54,9 @@ module.exports = async (context, req) => { body: `Ignored event type: ${eventType}`, }; } else if (eventType === 'push') { - if (req.body.repository.full_name ==='gitgitgadget/git-mailing-list-mirror') { + if (config.mailrepo.mirrorURL === `https://github.com/${req.body.repository.full_name}`) { context.res = { body: `push(${req.body.ref} in ${req.body.repository.full_name}): ` } - if (req.body.ref === 'refs/heads/lore-1') { + if (req.body.ref === config.mailrepo.mirrorRef) { const queued = await listWorkflowRuns(...a, 'handle-new-mails.yml', 'queued') if (queued.length) { context.res.body += [ @@ -72,7 +68,7 @@ module.exports = async (context, req) => { context.res.body += `triggered ${run.html_url}` } } else context.res.body += `Ignoring non-default branches` - } else if (req.body.repository.full_name !== 'git/git') { + } else if (req.body.repository.full_name !== `${config.repo.baseOwner}/${config.repo.name}`) { context.res = { body: `Ignoring pushes to ${req.body.repository.full_name}` } } else { const run = await triggerWorkflowDispatch( @@ -83,7 +79,7 @@ module.exports = async (context, req) => { } ) const extra = [] - if (req.body.ref === 'refs/heads/seen') { + if (config.repo.branches.map((name) => `refs/heads/${name}`).includes(req.body.ref)) { for (const workflow of ['update-prs.yml', 'update-mail-to-commit-notes.yml']) { if ((await listWorkflowRuns(...a, workflow, 'main', 'queued')).length === 0) { const run = await triggerWorkflowDispatch(...a, workflow, 'main') @@ -102,7 +98,7 @@ module.exports = async (context, req) => { } /* GitGitGadget works on dscho/git only for testing */ - if (repositoryOwner === 'dscho' && comment.user.login !== 'dscho') { + if (repositoryOwner === config.repo.testOwner && comment.user.login !== config.repo.testOwner) { throw new Error(`Ignoring comment from ${comment.user.login}`); } diff --git a/README.md b/README.md index b9317f0..e4f54a8 100644 --- a/README.md +++ b/README.md @@ -45,42 +45,100 @@ Finally, [run the Function locally](https://learn.microsoft.com/en-us/azure/azur You can also run/debug it via VS Code, there is a default configuration called "Attach to Node Functions". -## How this GitHub App was set up +## How to set up this GitHub App This process looks a bit complex, the main reason for that being that three things have to be set up essentially simultaneously: an Azure Function, a GitHub repository and a GitHub App. ### The Azure Function -First of all, a new [Azure Function](https://portal.azure.com/#blade/HubsExtension/BrowseResourceBlade/resourceType/Microsoft.Web%2Fsites/kind/functionapp) was created. A Linux one was preferred, for cost and performance reasons. Deployment with GitHub was _not_ yet configured. - -#### Getting the "publish profile" +First of all, a new [Azure Function](https://portal.azure.com/#blade/HubsExtension/BrowseResourceBlade/resourceType/Microsoft.Web%2Fsites/kind/functionapp) needs to be created. A Linux one is preferred, with a regular Consumption plan, for [cost](https://azure.microsoft.com/en-us/pricing/details/functions/) and performance reasons. Deployment with GitHub should _not_ yet be configured. + +#### Obtaining the Azure credentials + +The idea is to use [OpenID Connect](https://docs.github.com/en/actions/concepts/security/openid-connect) to log into Azure in the deploy workflow, _identifying_ as said workflow, via a "Managed Identity". This can be registered after the Azure Function has been successfully created: In an Azure CLI (for example [the one that is very neatly embedded in the Azure Portal](https://learn.microsoft.com/en-us/azure/cloud-shell/get-started/classic)), run this (after replacing the placeholders `{subscription-id}`, `{resource-group}` and `{app-name}`): + +```shell +az identity create --name -g +az identity federated-credential create \ + --identity-name \ + --resource-group \ + --name github-workflow \ + --issuer https://token.actions.githubusercontent.com \ + --subject repo:/gitgitgadget-github-app:environment:deploy-to-azure \ + --audiences api://AzureADTokenExchange +# The scope can be copied from the Azure Portal URL after navigating to the Azure Function +az role assignment create \ + --assignee \ + --scope '/subscriptions//resourceGroups//providers/Microsoft.Web/sites/' \ + --role 'Contributor' +``` -After the deployment succeeded, in the "Overview" tab, there is a "Get publish profile" link on the right panel at the center top. Clicking it will automatically download a `.json` file whose contents will be needed later. +The result is a "managed identity", essentially a tightly-scoped credential that allows deploying this particular Azure Function from that particular repository in a GitHub workflow run and that's it. This managed identity is identified via the `AZURE_CLIENT_ID`, `AZURE_TENANT_ID` and `AZURE_SUBSCRIPTION_ID` Actions secrets, more on that below. #### Some environment variables -A few environment variables will have to be configured for use with the Azure Function. This can be done on the "Configuration" tab, which is in the "Settings" group. +A few environment variables need to be configured for use with the Azure Function. This can be done on the "Configuration" tab, which is in the "Settings" group. -Concretely, the environment variables `GITHUB_WEBHOOK_SECRET` and `GITGITGADGET_TRIGGER_TOKEN` (a Personal Access Token to trigger the Azure Pipelines) need to be set. For the first, a generated random string was used. The second one was [created](https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Windows#create-a-pat) scoped to the Azure DevOps project `gitgitgadget` with the Build (read & execute) permissions. +Concretely, the environment variables `GITHUB_WEBHOOK_SECRET` needs to be set, any generated random string can be used as its value (and the same value needs to be used when eventually registering the actual GitHub App). -Also, the `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY` variables are needed in order to trigger GitHub workflow runs. These were obtained as part of registering the GitHub App. +Also, the `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY` variables are needed in order to trigger GitHub workflow runs. These are obtained as part of registering the GitHub App (see below). ### The repository -On https://github.com/, the `+` link on the top was pressed, and an empty, private repository was registered. Nothing was pushed to it yet. +Create a fork of https://github.com/gitgitgadget/gitgitgadget-github-app. Configure the Azure Managed Identity via Actions secrets, under the keys `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and `AZURE_SUBSCRIPTION_ID`. Also, the `AZURE_FUNCTION_NAME` secret needs to be defined (its value is the name of the Azure Function). -After that, the contents of the publish profile that [was downloaded earlier](#getting-the-publish-profile) were registered as Actions secret, under the name `AZURE_FUNCTIONAPP_PUBLISH_PROFILE`. +Also configure the repository _variable_ `DEPLOY_WITH_WORKFLOWS`; Its value must correspond to the fork of https://github.com/gitgitgadget/gitgitgadget-workflows, in the form `/gitgitgadget-workflows`. Note that that fork _must_ have a `config` branch that contains a valid project configuration in its `gitgitgadget-config.json` file. -This repository was initialized locally by forking https://github.com/gitgitgadget/gitgitgadget and separating out the Azure Functions part of it. Then, the test suite was developed and the GitHub workflows were adapted from https://github.com/git-for-windows/gfw-helper-github-app. After that, the `origin` remote was set to the newly registered repository on GitHub. - -As a last step, the repository was pushed, triggering the deployment to the Azure Function. +As a last step, on the Actions tab, the `Deploy to Azure` workflow needs to be triggered manually, which deploys the Azure Function. ### The GitHub App -Finally, the existing GitHub App's webhook URL was redirected to the new one. If there had not been an existing GitHub App, [a new GitHub App would have been registered](https://github.com/settings/apps/new) with https://github.com/gitgitgadget as homepage URL. +Now it is finally time to [register a new GitHub App](https://github.com/settings/apps/new) with https://github.com/> as homepage URL. -As Webhook URL, the URL of the Azure Function was used, which can be copied in the "Functions" tab of the Azure Function. It looks similar to this: https://my-github-app.azurewebsites.net/api/MyGitHubApp +As Webhook URL, use the URL of the Azure Function, which should look like this: https://.azurewebsites.net/api/GitGitGadget The value stored in the Azure Function as `GITHUB_WEBHOOK_SECRET` was used as Webhook secret. -The GitGitGadget GitHub app requires the following permissions: Read access to metadata, and Read and write access to checks, code, commit statuses, issues, pull requests, and workflows. \ No newline at end of file +The GitGitGadget GitHub app requires the following permissions: Read access to metadata, Read and write access to Variables, Actions, Checks, Commit statuses, Contents, Issues, Pull requests, and Workflows. It needs the following webhook events to be enabled: Check run, Commit comment, Issue comment, Pull request, Pull request review, Pull request review comment, Push, Repository, and Status. + +Once the GitHub App is successfully registered (and unfortunately only then), the private key can be generated via clicking the `Generate a private key` button in the "Private keys" section toward the bottom. This will automatically download a file; The contents of that file, with newlines replaced by `\n`, need to be configured as `GITHUB_APP_PRIVATE_KEY` environment variable in your Azure Function's `Settings>Environment variables` tab, and `GITHUB_APP_ID` needs to be set, too (it can be seen on the GitHub App's page at the top, labeled as "App ID"). + +The app needs to be installed on the fork of the `gitgitgadget-workflows` repository, and the app ID and private key should also be stored as Actions secrets in the fork of the `gitgitgadget-github-app` repository and it should be re-deployed so that it can pick up those new bits and pieces. + +#### Using `register-github-app-cli` + +A convenient alternative to clicky-clicky in the GitHub UI to register the GitHub is the convenient [`npx register-github-app-cli` command](https://github.com/gr2m/register-github-app-cli): Use it with `--org ` and a variation of this manifest: + +```yml +name: +url: https://github.com/apps/ +hook_attributes: + url: https://.azurewebsites.net/api/GitGitGadget +public: false +default_permissions: + actions: write + checks: write + commit_statuses: write + contents: write + issues: write + metadata: read + pull_requests: write + variables: read + workflows: write +default_events: + - check_run + - commit_comment + - issue_comment + - pull_request + - pull_request_review + - pull_request_review_comment + - push + - repository + - status +``` + +### A read-only GitHub App + +In complex setups, like the one for the Git project, there is more than one repository in which users open Pull Requests that then get forwarded by GitGitGadget: In addition to the "pr-repo", there can be an "upstream-repo" that is owned by the upstream project and should not allow GitGitGadget to write to it. + +To this end, a second GitHub App can be registered, one that lacks all permissions except Read/write on `issues` & `pull_requests` (to write PR comments) and `checks` (to mirror the workflow runs to the PRs). This will need to be configured on the `gitgitgadget-workflows` fork as `GITGITGADGET_READONLY_GITHUB_APP_ID` and `GITGITGADGET_READONLY_GITHUB_APP_PRIVATE_KEY` secrets. diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 797d958..3457364 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -162,7 +162,7 @@ testIssueComment('/test', async (context) => { testIssueComment('/verify-repository', 'nope', (context) => { expect(context.done).not.toHaveBeenCalled() expect(context.res).toEqual({ - body: 'Refusing to work on a repository other than gitgitgadget/git or git/git', + body: 'Refusing to work on any repository outside of gitgitgadget, git, dscho', 'status': 403, }) expect(mockRequest.write).not.toHaveBeenCalled()