Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .funcignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
/.*
/*.config.js
/*.md
/*.gif
/*.svg
/host.json
/local.settings.json
/package*.json
/test-*.js
13 changes: 11 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,24 @@ on:
- '.github/workflows/deploy.yml'
- 'GitForWindowsHelper/**'

permissions:
contents: read
id-token: write

jobs:
deploy:
if: github.event.repository.fork == false
environment: deploy-to-azure
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- 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: GitForWindowsHelper
publish-profile: ${{ secrets.AZURE_FUNCTIONAPP_PUBLISH_PROFILE }}
app-name: ${{ secrets.AZURE_FUNCTION_NAME || 'GitForWindowsHelper' }}
respect-funcignore: true
12 changes: 7 additions & 5 deletions GitForWindowsHelper/cascading-runs.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { activeBot, activeOrg } = require('./org')

const getToken = (() => {
const tokens = {}

Expand All @@ -12,7 +14,7 @@ const getToken = (() => {
})()

const isAllowed = async (context, owner, repo, login) => {
if (login === 'gitforwindowshelper[bot]') return true
if (login === `${activeBot}[bot]`) return true
const getCollaboratorPermissions = require('./get-collaborator-permissions')
const token = await getToken(context, owner, repo)
const permission = await getCollaboratorPermissions(context, token, owner, repo, login)
Expand All @@ -33,7 +35,7 @@ const triggerGitArtifactsRuns = async (context, checkRunOwner, checkRunRepo, tag
const owner = match[1]
const repo = match[2]
const workflowRunId = Number(match[3])
if (owner !== 'git-for-windows' || repo !== 'git-for-windows-automation') {
if (owner !== activeOrg || repo !== 'git-for-windows-automation') {
throw new Error(`Unexpected repository ${owner}/${repo} for tag-git run ${tagGitCheckRun.id}: ${tagGitCheckRun.url}`)
}

Expand Down Expand Up @@ -115,12 +117,12 @@ const cascadingRuns = async (context, req) => {
const checkRunRepo = req.body.repository.name
const checkRun = req.body.check_run
const name = checkRun.name
const sender = req.body.sender.login === 'ghost' && checkRun?.app?.slug === 'gitforwindowshelper'
? 'gitforwindowshelper[bot]' : req.body.sender.login
const sender = req.body.sender.login === 'ghost' && checkRun?.app?.slug === activeBot
? `${activeBot}[bot]` : req.body.sender.login

if (action === 'completed') {
if (name === 'tag-git') {
if (checkRunOwner !== 'git-for-windows' || checkRunRepo !== 'git') {
if (checkRunOwner !== activeOrg || checkRunRepo !== 'git') {
throw new Error(`Refusing to handle cascading run in ${checkRunOwner}/${checkRunRepo}`)
}

Expand Down
6 changes: 4 additions & 2 deletions GitForWindowsHelper/component-updates.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { activeOrg } = require('./org')

const guessComponentUpdateDetails = (title, body) => {
let [ , package_name, version ] =
title.match(/^\[New (\S+) version\] (?:[^0-9]+\s+)?(\S+(?:\s+patch\s+\d+)?)(?! new items)/) ||
Expand Down Expand Up @@ -115,14 +117,14 @@ const guessReleaseNotes = async (context, issue) => {
const match = issue.body.match(/See (https:\/\/\S+) for details/)
if (match) return match[1]

const issueMatch = issue.body.match(/https:\/\/github\.com\/git-for-windows\/git\/issues\/(\d+)/)
const issueMatch = issue.body.match(new RegExp(`https://github.com/${activeOrg}/git/issues/(\\d+)`))
if (issueMatch) {
const githubApiRequest = require('./github-api-request')
const issue = await githubApiRequest(
context,
null,
'GET',
`/repos/git-for-windows/git/issues/${issueMatch[1]}`
`/repos/${activeOrg}/git/issues/${issueMatch[1]}`
)
return matchURLInIssue(issue)
}
Expand Down
10 changes: 6 additions & 4 deletions GitForWindowsHelper/finalize-g4w-release.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
const { activeOrg, activeBot } = require('./org')

module.exports = async (context, req) => {
if (req.body.action !== 'completed') return "Nothing to do here: workflow run did not complete yet"
if (req.body.workflow_run.conclusion !== 'success') return "Nothing to do here: workflow run did not succeed"

const releaseWorkflowRunID = req.body.workflow_run.id
const owner = 'git-for-windows'
const owner = activeOrg
const repo = 'git'
const sender = req.body.sender.login

Expand All @@ -21,7 +23,7 @@ module.exports = async (context, req) => {
})()

const isAllowed = async (login) => {
if (login === 'gitforwindowshelper[bot]') return true
if (login === `${activeBot}[bot]`) return true
const getCollaboratorPermissions = require('./get-collaborator-permissions')
const token = await getToken()
const permission = await getCollaboratorPermissions(context, token, owner, repo, login)
Expand All @@ -31,11 +33,11 @@ module.exports = async (context, req) => {
if (!await isAllowed(sender)) throw new Error(`${sender} is not allowed to do that`)

const { searchIssues } = require('./search')
const items = await searchIssues(context, 'org:git-for-windows is:pr is:open in:comments "The release-git workflow run was started"')
const items = await searchIssues(context, `org:${activeOrg} is:pr is:open in:comments "The release-git workflow run was started"`)

const githubApiRequest = require('./github-api-request')

const needle = `The \`release-git\` workflow run [was started](https://github.com/git-for-windows/git-for-windows-automation/actions/runs/${releaseWorkflowRunID})`
const needle = `The \`release-git\` workflow run [was started](https://github.com/${activeOrg}/git-for-windows-automation/actions/runs/${releaseWorkflowRunID})`
const candidates = []
for (const item of items) {
if (!['OWNER', 'MEMBER'].includes(item.author_association)) continue
Expand Down
2 changes: 1 addition & 1 deletion GitForWindowsHelper/https-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const httpsRequest = async (context, hostname, method, requestPath, body, header
options.method === 'GET' ? '' : `-X ${options.method}`,
...Object.entries(options.headers).map(([key, value]) => `-H ${quote(`${key}: ${value}`)}`),
body ? `-d ${quote(body)}` : '',
`https://${options.hostname}${options.path}`,
`'https://${options.hostname}${encodeURI(options.path)}'`,
].filter(e => e).join(' ')
;(context.error || console.error)(commandLine)
}
Expand Down
7 changes: 4 additions & 3 deletions GitForWindowsHelper/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const validateGitHubWebHook = require('./validate-github-webhook')
const { activeBot, activeOrg } = require('./org')

module.exports = async function (context, req) {
const withStatus = (status, headers, body) => {
Expand Down Expand Up @@ -41,7 +42,7 @@ module.exports = async function (context, req) {
if (req.headers['x-github-event'] === 'workflow_run'
&& req.body.workflow_run?.event === 'workflow_dispatch'
&& req.body.workflow_run?.head_branch === 'main'
&& req.body.repository.full_name === 'git-for-windows/git-for-windows-automation'
&& req.body.repository.full_name === `${activeOrg}/git-for-windows-automation`
&& req.body.action === 'completed'
&& req.body.workflow_run.path === '.github/workflows/release-git.yml'
&& req.body.workflow_run.conclusion === 'success') return ok(await finalizeGitForWindowsRelease(context, req))
Expand All @@ -53,8 +54,8 @@ module.exports = async function (context, req) {
try {
const { cascadingRuns, handlePush } = require('./cascading-runs.js')
if (req.headers['x-github-event'] === 'check_run'
&& req.body.check_run?.app?.slug === 'gitforwindowshelper'
&& req.body.repository.full_name === 'git-for-windows/git'
&& req.body.check_run?.app?.slug === activeBot
&& req.body.repository.full_name === `${activeOrg}/git`
&& req.body.action === 'completed') return ok(await cascadingRuns(context, req))

if (req.headers['x-github-event'] === 'push'
Expand Down
4 changes: 4 additions & 0 deletions GitForWindowsHelper/org.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
activeOrg: process.env.ACTIVE_ORG || 'git-for-windows',
activeBot: process.env.ACTIVE_BOT || 'gitforwindowshelper',
}
34 changes: 18 additions & 16 deletions GitForWindowsHelper/slash-commands.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { activeOrg } = require('./org')

module.exports = async (context, req) => {
const command = req.body.comment.body
const owner = req.body.repository.owner.login
Expand Down Expand Up @@ -50,7 +52,7 @@ module.exports = async (context, req) => {

try {
if (command === '/open pr') {
if (owner !== 'git-for-windows' || !['git', 'msys2-runtime'].includes(repo)) return `Ignoring ${command} in unexpected repo: ${commentURL}`
if (owner !== activeOrg || !['git', 'msys2-runtime'].includes(repo)) return `Ignoring ${command} in unexpected repo: ${commentURL}`

await checkPermissions()

Expand All @@ -68,7 +70,7 @@ module.exports = async (context, req) => {
const openPR = async (package_name, packageType) => {
const { searchIssues } = require('./search')
const prTitle = `${package_name}: update to ${version}`
const items = await searchIssues(context, `org:git-for-windows is:pull-request "${prTitle}" in:title`)
const items = await searchIssues(context, `org:${activeOrg} is:pull-request "${prTitle}" in:title`)
const alreadyOpenedPR = items.filter(e => e.title === prTitle)

const { appendToIssueComment } = require('./issues');
Expand All @@ -91,7 +93,7 @@ module.exports = async (context, req) => {
const answer = await triggerWorkflowDispatch(
context,
await getToken(),
'git-for-windows',
activeOrg,
'git-for-windows-automation',
'open-pr.yml',
'main', {
Expand All @@ -112,7 +114,7 @@ module.exports = async (context, req) => {
}

if (command === '/updpkgsums') {
if (owner !== 'git-for-windows'
if (owner !== activeOrg
|| !req.body.issue.pull_request
|| !['build-extra', 'MINGW-packages', 'MSYS2-packages'].includes(repo)) {
return `Ignoring ${command} in unexpected repo: ${commentURL}`
Expand All @@ -125,7 +127,7 @@ module.exports = async (context, req) => {
const answer = await triggerWorkflowDispatch(
context,
await getToken(),
'git-for-windows',
activeOrg,
'git-for-windows-automation',
'updpkgsums.yml',
'main', {
Expand All @@ -142,7 +144,7 @@ module.exports = async (context, req) => {

const deployMatch = command.match(/^\/deploy(\s+(\S+)\s*)?$/)
if (deployMatch) {
if (owner !== 'git-for-windows'
if (owner !== activeOrg
|| !req.body.issue.pull_request
|| !['build-extra', 'MINGW-packages', 'MSYS2-packages'].includes(repo)) {
return `Ignoring ${command} in unexpected repo: ${commentURL}`
Expand All @@ -169,7 +171,7 @@ module.exports = async (context, req) => {
await triggerWorkflowDispatch(
context,
await getToken(),
'git-for-windows',
activeOrg,
'git-for-windows-automation',
'build-and-deploy.yml',
'main', {
Expand Down Expand Up @@ -223,7 +225,7 @@ module.exports = async (context, req) => {
e.id = await queueCheckRun(
context,
await getToken(),
'git-for-windows',
activeOrg,
repo,
ref,
deployLabel,
Expand Down Expand Up @@ -254,7 +256,7 @@ module.exports = async (context, req) => {
await updateCheckRun(
context,
await getToken(),
'git-for-windows',
activeOrg,
repo,
e.id, {
details_url: e.answer.html_url
Expand All @@ -266,7 +268,7 @@ module.exports = async (context, req) => {
}

if (command === '/git-artifacts') {
if (owner !== 'git-for-windows'
if (owner !== activeOrg
|| repo !== 'git'
|| !req.body.issue.pull_request
) {
Expand Down Expand Up @@ -327,7 +329,7 @@ module.exports = async (context, req) => {
const answer = await triggerWorkflowDispatch(
context,
await getToken(),
'git-for-windows',
activeOrg,
'git-for-windows-automation',
'tag-git.yml',
'main', {
Expand Down Expand Up @@ -389,7 +391,7 @@ module.exports = async (context, req) => {
const releaseCheckRunId = await queueCheckRun(
context,
await getToken(),
'git-for-windows',
activeOrg,
repo,
commitSHA,
'github-release',
Expand Down Expand Up @@ -425,7 +427,7 @@ module.exports = async (context, req) => {
const owner = match[1]
const repo = match[2]
workFlowRunIDs[architecture] = match[3]
if (owner !== 'git-for-windows' || repo !== 'git-for-windows-automation') {
if (owner !== activeOrg || repo !== 'git-for-windows-automation') {
throw new Error(`Unexpected repository ${owner}/${repo} for git-artifacts run ${latest.id}: ${latest.url}`)
}

Expand Down Expand Up @@ -457,7 +459,7 @@ module.exports = async (context, req) => {
const answer = await triggerWorkflowDispatch(
context,
await getToken(),
'git-for-windows',
activeOrg,
'git-for-windows-automation',
'release-git.yml',
'main', {
Expand Down Expand Up @@ -492,7 +494,7 @@ module.exports = async (context, req) => {

const relNotesMatch = command.match(/^\/add (relnote|release ?note)(\s+(blurb|feature|bug)\s+([^]*))?$/i)
if (relNotesMatch) {
if (owner !== 'git-for-windows'
if (owner !== activeOrg
|| !['git', 'build-extra', 'MINGW-packages', 'MSYS2-packages'].includes(repo)) {
return `Ignoring ${command} in unexpected repo: ${commentURL}`
}
Expand All @@ -519,7 +521,7 @@ module.exports = async (context, req) => {
const answer = await triggerWorkflowDispatch(
context,
await getToken(),
'git-for-windows',
activeOrg,
'build-extra',
'add-release-note.yml',
'main', {
Expand Down
12 changes: 9 additions & 3 deletions GitForWindowsHelper/trigger-workflow-dispatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,21 @@ const triggerWorkflowDispatch = async (context, token, owner, repo, workflow_id,
if ('true' === process.env.DO_NOT_TRIGGER_ANYTHING) {
throw new Error(`Would have triggered workflow ${workflow_id} on ${owner}/${repo} with ref ${ref} and inputs ${JSON.stringify(inputs)}`)
}
const { headers: { date } } = await githubApiRequest(
const response = await githubApiRequest(
context,
token,
'POST',
`/repos/${owner}/${repo}/actions/workflows/${workflow_id}/dispatches`,
{ ref, inputs }
{ ref, inputs, return_run_details: true }
)

const runs = await waitForWorkflowRun(context, owner, repo, workflow_id, new Date(date).toISOString(), token)
// If the API returned run details (200), use them directly
if (response.workflow_run_id) return response
Comment thread
dscho marked this conversation as resolved.

// Fall back to polling if we got a 204 (no run details)
const date = response.headers?.date || new Date().toISOString()
const after = new Date(Date.parse(date) - 5000).toISOString()
const runs = await waitForWorkflowRun(context, owner, repo, workflow_id, after, token)
return runs[0]
}

Expand Down
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,27 @@ This process looks a bit complex, but the main reason for that is that three thi

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"
#### 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>/gfw-helper-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

Expand All @@ -123,9 +141,9 @@ Concretely, the environment variables `GITHUB_WEBHOOK_SECRET`, `GITHUB_APP_PRIVA

### 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/git-for-windows/gfw-helper-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) was registered as Actions secret, under the name `AZURE_FUNCTIONAPP_PUBLISH_PROFILE`.
After that, the Azure Service Principal needs to be registered as Actions secret, under the name `AZURE_RBAC_CREDENTIALS`.

This repository was initialized locally only after that, actually, by starting to write this `README.md` and then developing this working toy GitHub App, and the `origin` remote was set to the newly registered repository on GitHub.

Expand Down
Loading