Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .funcignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@
/*.md
/host.json
/local.settings.json
/package.json
/package-lock.json
/jest.config.js
45 changes: 42 additions & 3 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -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-config.json >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
78 changes: 78 additions & 0 deletions GitGitGadget/gitgitgadget-config.json
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"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"
}
}
24 changes: 10 additions & 14 deletions GitGitGadget/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,18 @@ 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}`);
const repositoryOwner = req.body.repository.owner.login;
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') {
Expand All @@ -59,9 +55,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 += [
Expand All @@ -73,7 +69,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(
Expand All @@ -84,7 +80,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')
Expand All @@ -103,7 +99,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}`);
}

Expand Down
90 changes: 74 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <managed-identity-name> -g <resource-group>
az identity federated-credential create \
--identity-name <managed-identity-name> \
--resource-group <resource-group> \
--name github-workflow \
--issuer https://token.actions.githubusercontent.com \
--subject repo:<org>/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 <client-id-of-managed-identity> \
--scope '/subscriptions/<subscription-id>/resourceGroups/<resource-group>/providers/Microsoft.Web/sites/<azure-function-name>' \
--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 `<org>/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/<org>> 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://<azure-function-name>.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.
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 <owning-organization>` and a variation of this manifest:

```yml
name: <name>
url: https://github.com/apps/<name>
hook_attributes:
url: https://<function-app-name>.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.
2 changes: 1 addition & 1 deletion __tests__/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ testIssueComment('/test', async (context) => {
testIssueComment('/verify-repository', 'nope', (context) => {
expect(context.done).toHaveBeenCalledTimes(1)
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()
Expand Down