diff --git a/.vscode/launch.json b/.vscode/launch.json index a7bfabce..c024c704 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,8 +5,8 @@ "type": "node", "request": "launch", "name": "Debug Tests", - "program": "${workspaceRoot}/node_modules/.bin/jest", - "cwd": "${workspaceRoot}", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "cwd": "${workspaceFolder}", // to restrict running to a particular test case, add something like "--testNamePattern=/deploy" "args": ["--runInBand", "--config", "jest.config.js"], "windows": { @@ -14,6 +14,21 @@ }, "console": "integratedTerminal" }, + { + "type": "node", + "request": "launch", + "name": "Test event delivery", + "runtimeArgs": [ + "--inspect-brk", + ], + "stopOnEntry": false, + "cwd": "${workspaceFolder}", + "args": [ + "${workspaceFolder}/test-pr-comment-delivery.js", + "${workspaceFolder}/example-check-run-git-artifacts-completed.yml", + ], + "console": "integratedTerminal" + }, { "name": "Attach to Node Functions", "type": "node", diff --git a/GitForWindowsHelper/cascading-runs.js b/GitForWindowsHelper/cascading-runs.js index f0e6302a..961578d5 100644 --- a/GitForWindowsHelper/cascading-runs.js +++ b/GitForWindowsHelper/cascading-runs.js @@ -133,12 +133,314 @@ const cascadingRuns = async (context, req) => { return comment } + if (checkRunOwner === 'git-for-windows' + && checkRunRepo === 'git' + && name.startsWith('git-artifacts-')) { + const output = req.body.check_run.output + const match = output.summary.match( + /Build Git (\S+) artifacts from commit (\S+) \(tag-git run #(\d+)\)/ + ) + if (!match) throw new Error( + `Could not parse 'summary' attribute of check-run ${req.body.check_run.id}: ${output.summary}` + ) + const [, ver, commit, tagGitWorkflowRunID] = match + const snapshotTag = `prerelease-${ver.replace(/^v/, '')}` + + // First, verify that the snapshot has not been uploaded yet + const gitSnapshotsToken = await getToken(context, checkRunOwner, 'git-snapshots') + const githubApiRequest = require('./github-api-request') + try { + const releasePath = `${checkRunOwner}/git-snapshots/releases/tags/${snapshotTag}` + await githubApiRequest( + context, + gitSnapshotsToken, + 'GET', + `/repos/${releasePath}`, + ) + return `Ignoring ${name} check-run because the snapshot for ${commit} was already uploaded` + + ` to https://github.com/${releasePath}` + } catch(e) { + if (e?.statusCode !== 404) throw e + // The snapshot does not exist yet + } + + // Next, check that the commit is on the `main` branch + const gitToken = await getToken(context, checkRunOwner, checkRunRepo) + const { behind_by } = await githubApiRequest( + context, + gitToken, + 'GET', + `/repos/${checkRunOwner}/${checkRunRepo}/compare/HEAD...${commit}`, + ) + if (behind_by > 0) { + return `Ignoring ${name} check-run because its corresponding commit ${commit} is not on the main branch` + } + + const workFlowRunIDs = {} + const { listCheckRunsForCommit, queueCheckRun } = require('./check-runs') + for (const architecture of ['x86_64', 'i686', 'aarch64']) { + const workflowName = `git-artifacts-${architecture}` + const runs = name === workflowName ? [req.body.check_run] : await listCheckRunsForCommit( + context, + gitToken, + checkRunOwner, + checkRunRepo, + commit, + workflowName + ) + const needle = + `Build Git ${ver} artifacts from commit ${commit} (tag-git run #${tagGitWorkflowRunID})` + const latest = runs + .filter(run => run.output.summary === needle) + .sort((a, b) => a.id - b.id) + .pop() + if (latest) { + if (latest.status !== 'completed') { + return `The '${workflowName}' run at ${latest.html_url} did not complete yet.` + } + if (latest.conclusion !== 'success') { + throw new Error(`The '${workflowName}' run at ${latest.html_url} did not succeed.`) + } + + const match = latest.output.text.match( + /For details, see \[this run\]\(https:\/\/github.com\/([^/]+)\/([^/]+)\/actions\/runs\/(\d+)\)/ + ) + if (!match) throw new Error(`Unhandled 'text' attribute of git-artifacts run ${latest.id}: ${latest.url}`) + const owner = match[1] + const repo = match[2] + workFlowRunIDs[architecture] = match[3] + if (owner !== 'git-for-windows' || repo !== 'git-for-windows-automation') { + throw new Error(`Unexpected repository ${owner}/${repo} for git-artifacts run ${latest.id}: ${latest.url}`) + } + } else { + return `Won't trigger 'upload-snapshot' in reaction to ${name} because the '${workflowName}' run does not exist.` + } + } + + const checkRunTitle = `Upload snapshot ${snapshotTag}` + await queueCheckRun( + context, + gitToken, + 'git-for-windows', + 'git', + commit, + 'upload-snapshot', + checkRunTitle, + checkRunTitle + ) + + const gitForWindowsAutomationToken = + await getToken(context, checkRunOwner, 'git-for-windows-automation') + const triggerWorkflowDispatch = require('./trigger-workflow-dispatch') + const answer = await triggerWorkflowDispatch( + context, + gitForWindowsAutomationToken, + 'git-for-windows', + 'git-for-windows-automation', + 'upload-snapshot.yml', + 'main', { + git_artifacts_x86_64_workflow_run_id: workFlowRunIDs['x86_64'], + git_artifacts_i686_workflow_run_id: workFlowRunIDs['i686'], + git_artifacts_aarch64_workflow_run_id: workFlowRunIDs['aarch64'], + } + ) + + return `The 'upload-snapshot' workflow run was started at ${answer.html_url}` + } return `Not a cascading run: ${name}; Doing nothing.` } return `Unhandled action: ${action}` } +const handlePush = async (context, req) => { + const pushOwner = req.body.repository.owner.login + const pushRepo = req.body.repository.name + const ref = req.body.ref + const commit = req.body.after + + if (pushOwner !== 'git-for-windows' || pushRepo !== 'git') { + throw new Error(`Refusing to handle push to ${pushOwner}/${pushRepo}`) + } + + if (ref !== 'refs/heads/main') return `Ignoring push to ${ref}` + + // See whether there was are already a `tag-git` check-run for this commit + const { listCheckRunsForCommit, queueCheckRun, updateCheckRun } = require('./check-runs') + const gitToken = await getToken(context, pushOwner, pushRepo) + const runs = await listCheckRunsForCommit( + context, + gitToken, + pushOwner, + pushRepo, + commit, + 'tag-git' + ) + + const latest = runs + .sort((a, b) => a.id - b.id) + .pop() + + if (latest && latest.status !== 'completed') throw new Error(`The 'tag-git' run at ${latest.html_url} did not complete yet before ${commit} was pushed to ${ref}!`) + + const gitForWindowsAutomationToken = + await getToken(context, pushOwner, 'git-for-windows-automation') + const triggerWorkflowDispatch = require('./trigger-workflow-dispatch') + if (!latest) { + // There is no `tag-git` workflow run; Trigger it to build a new snapshot + const tagGitCheckRunTitle = `Tag snapshot Git @${commit}` + const tagGitCheckRunId = await queueCheckRun( + context, + gitForWindowsAutomationToken, + pushOwner, + pushRepo, + commit, + 'tag-git', + tagGitCheckRunTitle, + tagGitCheckRunTitle + ) + + try { + const answer = await triggerWorkflowDispatch( + context, + gitForWindowsAutomationToken, + pushOwner, + 'git-for-windows-automation', + 'tag-git.yml', + 'main', { + rev: commit, + owner: pushOwner, + repo: pushRepo, + snapshot: 'true' + } + ) + return `The 'tag-git' workflow run was started at ${answer.html_url}` + } catch (e) { + await updateCheckRun( + context, + gitForWindowsAutomationToken, + pushOwner, + pushRepo, + tagGitCheckRunId, { + status: 'completed', + conclusion: 'failure', + output: { + title: tagGitCheckRunTitle, + summary: tagGitCheckRunTitle, + text: e.message || JSON.stringify(e, null, 2) + } + } + ) + throw e + } + } + + if (latest.conclusion !== 'success') throw new Error( + `The 'tag-git' run at ${latest.html_url} did not succeed (conclusion = ${latest.conclusion}).` + ) + + // There is already a `tag-git` workflow run; Is there already an `upload-snapshot` run? + const latestUploadSnapshotRun = (await listCheckRunsForCommit( + context, + gitToken, + pushOwner, + pushRepo, + commit, + 'upload-snapshot' + )).pop() + if (latestUploadSnapshotRun) return `The 'upload-snapshot' check-run already exists for ${commit}: ${latestUploadSnapshotRun.html_url}` + + // Trigger the `upload-snapshot` run directly + const tagGitCheckRunTitle = `Upload snapshot Git @${commit}` + const tagGitCheckRunId = await queueCheckRun( + context, + await getToken(), + pushOwner, + pushRepo, + commit, + 'tag-git', + tagGitCheckRunTitle, + tagGitCheckRunTitle + ) + + const match = latest.output.summary.match(/^Tag Git (\S+) @([0-9a-f]+)$/) + if (!match) throw new Error(`Unexpected summary '${latest.output.summary}' of tag-git run: ${latest.html_url}`) + if (!match[2] === commit) throw new Error(`Unexpected revision ${match[2]} '${latest.output.summary}' of tag-git run: ${latest.html_url}`) + const ver = match[1] + + try { + const workFlowRunIDs = {} + for (const architecture of ['x86_64', 'i686', 'aarch64']) { + const workflowName = `git-artifacts-${architecture}` + const runs = await listCheckRunsForCommit( + context, + gitToken, + pushOwner, + pushRepo, + commit, + workflowName + ) + const needle = + `Build Git ${ver} artifacts from commit ${commit} (tag-git run #${latest.id})` + const latest2 = runs + .filter(run => run.output.summary === needle) + .sort((a, b) => a.id - b.id) + .pop() + if (latest2) { + if (latest2.status !== 'completed' || latest2.conclusion !== 'success') { + throw new Error(`The '${workflowName}' run at ${latest2.html_url} did not succeed.`) + } + + const match = latest2.output.text.match( + /For details, see \[this run\]\(https:\/\/github.com\/([^/]+)\/([^/]+)\/actions\/runs\/(\d+)\)/ + ) + if (!match) throw new Error(`Unhandled 'text' attribute of git-artifacts run ${latest2.id}: ${latest2.url}`) + const owner = match[1] + const repo = match[2] + workFlowRunIDs[architecture] = match[3] + if (owner !== 'git-for-windows' || repo !== 'git-for-windows-automation') { + throw new Error(`Unexpected repository ${owner}/${repo} for git-artifacts run ${latest2.id}: ${latest2.url}`) + } + } else { + return `Won't trigger 'upload-snapshot' on pushing ${commit} because the '${workflowName}' run does not exist.` + } + } + + const answer = await triggerWorkflowDispatch( + context, + gitForWindowsAutomationToken, + pushRepo, + 'git-for-windows-automation', + 'upload-snapshot.yml', + 'main', { + git_artifacts_x86_64_workflow_run_id: workFlowRunIDs['x86_64'], + git_artifacts_i686_workflow_run_id: workFlowRunIDs['i686'], + git_artifacts_aarch64_workflow_run_id: workFlowRunIDs['aarch64'], + } + ) + + return `The 'upload-snapshot' workflow run was started at ${answer.html_url}` + } catch (e) { + await updateCheckRun( + context, + gitForWindowsAutomationToken, + pushOwner, + pushRepo, + tagGitCheckRunId, { + status: 'completed', + conclusion: 'failure', + output: { + title: tagGitCheckRunTitle, + summary: tagGitCheckRunTitle, + text: e.message || JSON.stringify(e, null, 2) + } + } + ) + throw e + } +} + module.exports = { triggerGitArtifactsRuns, - cascadingRuns + cascadingRuns, + handlePush } \ No newline at end of file diff --git a/GitForWindowsHelper/index.js b/GitForWindowsHelper/index.js index 6c72a6b7..dc5c325d 100644 --- a/GitForWindowsHelper/index.js +++ b/GitForWindowsHelper/index.js @@ -62,10 +62,13 @@ module.exports = async function (context, req) { } try { - const { cascadingRuns } = require('./cascading-runs.js') + const { cascadingRuns, handlePush } = require('./cascading-runs.js') if (req.headers['x-github-event'] === 'check_run' && req.body.repository.full_name === 'git-for-windows/git' && req.body.action === 'completed') return ok(await cascadingRuns(context, req)) + + if (req.headers['x-github-event'] === 'push' + && req.body.repository.full_name === 'git-for-windows/git') return ok(await handlePush(context, req)) } catch (e) { context.log(e) return withStatus(500, undefined, e.message || JSON.stringify(e, null, 2)) diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 8ea561a3..656763ab 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -169,6 +169,14 @@ The \`git-artifacts-aarch64\` workflow run [was started](dispatched-workflow-git `) return { html_url: 'https://github.com/git-for-windows/git/pull/4322#issuecomment-1450703020' } } + if (method === 'GET' && requestPath === + '/repos/git-for-windows/git-snapshots/releases/tags/prerelease-2.48.0-rc2.windows.1-472-g0c796d3013-20250128120446') { + throw { statusCode: 404 } + } + if (method === 'GET' && requestPath === + '/repos/git-for-windows/git/compare/HEAD...0c796d3013a57e8cc894c152f0200107226e5dd1') { + return { behind_by: 0 } + } throw new Error(`Unhandled ${method}-${requestPath}-${JSON.stringify(payload)}`) }) jest.mock('../GitForWindowsHelper/github-api-request', () => { @@ -426,6 +434,19 @@ let mockListCheckRunsForCommit = jest.fn((_context, _token, _owner, _repo, rev, } return [{ id, status: 'completed', conclusion: 'success', output }] } + if (rev === '0c796d3013a57e8cc894c152f0200107226e5dd1') { + const id = { + 'git-artifacts-x86_64': 13010015190, + 'git-artifacts-i686': 13010015938, + 'git-artifacts-aarch64': 13010016895 + }[checkRunName] + const output = { + title: 'Build Git v2.48.0-rc2.windows.1-472-g0c796d3013-20250128120446 artifacts', + summary: 'Build Git v2.48.0-rc2.windows.1-472-g0c796d3013-20250128120446 artifacts from commit 0c796d3013a57e8cc894c152f0200107226e5dd1 (tag-git run #13009996573)', + text: `For details, see [this run](https://github.com/git-for-windows/git-for-windows-automation/actions/runs/${id})` + } + return [{ id, status: 'completed', conclusion: 'success', output }] + } if (rev === 'dee501d15') { if (checkRunName === 'tag-git') return [{ status: 'completed', @@ -439,6 +460,29 @@ let mockListCheckRunsForCommit = jest.fn((_context, _token, _owner, _repo, rev, }] return [] } + if (rev === '88811') { + if (checkRunName === 'tag-git') return [{ + conclusion: 'success', + status: 'completed', + output: { + summary: 'Tag Git already-tagged @88811' + }, + id: 123 + }] + if (checkRunName.startsWith('git-artifacts')) { + const id = { + 'git-artifacts-x86_64': 8664, + 'git-artifacts-i686': 686, + 'git-artifacts-aarch64':64, + }[checkRunName] + const output = { + title: 'Build already-tagged artifacts', + summary: 'Build Git already-tagged artifacts from commit 88811 (tag-git run #123)', + text: `For details, see [this run](https://github.com/git-for-windows/git-for-windows-automation/actions/runs/${id})` + } + return [{ id, status: 'completed', conclusion: 'success', output }] + } + } if (checkRunName === 'git-artifacts-x86_64') return [{ status: 'completed', conclusion: 'success', @@ -917,3 +961,107 @@ test('a completed `release-git` run updates the `main` branch in git-for-windows throw e; } }) + +test('the third completed `git-artifacts-` check-run triggers an `upload-snapshot`', async () => { + const context = makeContext({ + action: 'completed', + check_run: { + name: 'git-artifacts-aarch64', + head_sha: '0c796d3013a57e8cc894c152f0200107226e5dd1', + status: 'completed', + conclusion: 'success', + details_url: 'https://url-to-git-artifacts-aarch64/', + output: { + title: 'Build Git v2.48.0-rc2.windows.1-472-g0c796d3013-20250128120446 artifacts', + summary: 'Build Git v2.48.0-rc2.windows.1-472-g0c796d3013-20250128120446 artifacts from commit 0c796d3013a57e8cc894c152f0200107226e5dd1 (tag-git run #13009996573)', + text: 'For details, see [this run](https://github.com/git-for-windows/git-for-windows-automation/actions/runs/13010016895).' + } + }, + installation: { + id: 123 + }, + repository: { + name: 'git', + owner: { + login: 'git-for-windows' + }, + full_name: 'git-for-windows/git' + } + }, { + 'x-github-event': 'check_run' + }) + + try { + expect(await index(context, context.req)).toBeUndefined() + expect(context.res).toEqual({ + body: `The 'upload-snapshot' workflow run was started at dispatched-workflow-upload-snapshot.yml`, + headers: undefined, + status: undefined + }) + expect(mockGitHubApiRequest).toHaveBeenCalled() + expect(mockGitHubApiRequest.mock.calls[0].slice(1)).toEqual([ + 'installation-access-token', + 'GET', + '/repos/git-for-windows/git-snapshots/releases/tags/prerelease-2.48.0-rc2.windows.1-472-g0c796d3013-20250128120446' + ]) + expect(mockGitHubApiRequest.mock.calls[2].slice(1)).toEqual([ + 'installation-access-token', + 'POST', + '/repos/git-for-windows/git-for-windows-automation/actions/workflows/upload-snapshot.yml/dispatches', { + ref: 'main', + inputs: { + git_artifacts_aarch64_workflow_run_id: "13010016895", + git_artifacts_i686_workflow_run_id: "13010015938", + git_artifacts_x86_64_workflow_run_id: "13010015190" + } + } + ]) + } catch (e) { + context.log.mock.calls.forEach(e => console.log(e[0])) + throw e; + } +}) + +test('a `push` triggers a `tag-git` or an `upload-snapshot` run', async () => { + const context = makeContext({ + ref: 'refs/heads/main', + after: 'no-tag-git-yet', + installation: { + id: 123 + }, + repository: { + name: 'git', + owner: { + login: 'git-for-windows' + }, + full_name: 'git-for-windows/git' + } + }, { + 'x-github-event': 'push' + }) + + try { + expect(await index(context, context.req)).toBeUndefined() + expect(context.res).toEqual({ + body: `The 'tag-git' workflow run was started at dispatched-workflow-tag-git.yml`, + headers: undefined, + status: undefined + }) + } catch (e) { + context.log.mock.calls.forEach(e => console.log(e[0])) + throw e; + } + + context.req.body.after = '88811' + try { + expect(await index(context, context.req)).toBeUndefined() + expect(context.res).toEqual({ + body: `The 'upload-snapshot' workflow run was started at dispatched-workflow-upload-snapshot.yml`, + headers: undefined, + status: undefined + }) + } catch (e) { + context.log.mock.calls.forEach(e => console.log(e[0])) + throw e; + } +}) diff --git a/test-pr-comment-delivery.js b/test-pr-comment-delivery.js index 1e75b8f8..c9e53d36 100755 --- a/test-pr-comment-delivery.js +++ b/test-pr-comment-delivery.js @@ -4,7 +4,7 @@ const fs = require('fs') // Expect a path as command-line parameter that points to a file containing - // an event copy/pasted from + // the output of `get-webhook-event-payload.js`, or an event copy/pasted from // https://github.com/organizations/git-for-windows/settings/apps/gitforwindowshelper/advanced // in the form: // @@ -20,7 +20,10 @@ // } const path = process.argv[2] - const contents = fs.readFileSync(path).toString('utf-8') + const contents = fs + .readFileSync(path) + .toString('utf-8') + .replace(/^((id|action): .*\n)*/g, "") const req = { headers: {}