From 45ea8340be214cd78f525ee001e586147860598b Mon Sep 17 00:00:00 2001 From: liushuyu Date: Thu, 30 Dec 2021 03:31:18 -0700 Subject: [PATCH] CI: do canary merge and nightly publishing ... ... on the GitHub Actions. This will remove the reliance on the backend server merging script and more transparent to the other contributors --- .github/workflows/ci-merge.js | 182 ++++++++++++++++++++++++++++++++++ .github/workflows/publish.yml | 99 ++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 .github/workflows/ci-merge.js create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/ci-merge.js b/.github/workflows/ci-merge.js new file mode 100644 index 000000000..d80091b3e --- /dev/null +++ b/.github/workflows/ci-merge.js @@ -0,0 +1,182 @@ +// Note: This is a GitHub Actions script +// It is not meant to be executed directly on your machine without modifications + +const fs = require("fs"); + +async function checkCanaryChanges(github, context) { + const delta = new Date() - new Date(context.payload.repository.pushed_at); + if (delta <= 86400000) return true; + const query = `query($owner:String!, $name:String!, $label:String!) { + repository(name:$name, owner:$owner) { + pullRequests(labels: [$label], states: OPEN, first: 100) { + nodes { number headRepository { pushedAt } } + } + } + }`; + const variables = { + owner: context.repo.owner, + name: context.repo.repo, + label: "canary-merge", + }; + const result = await github.graphql(query, variables); + const pulls = result.repository.pullRequests.nodes; + for (let i = 0; i < pulls.length; i++) { + let pull = pulls[i]; + if (new Date() - new Date(pull.headRepository.pushedAt) <= 86400000) { + console.info(`${pull.number} updated at ${pull.headRepository.pushedAt}`); + return true; + } + } + console.info("No changes detected in any tagged pull requests."); + return false; +} + +async function tagAndPush(github, owner, repo, execa, commit=false) { + let altToken = process.env.ALT_GITHUB_TOKEN; + if (!altToken) { + throw `Please set ALT_GITHUB_TOKEN environment variable. This token should have write access to ${owner}/${repo}.`; + } + const query = `query ($owner:String!, $name:String!) { + repository(name:$name, owner:$owner) { + refs(refPrefix: "refs/tags/", orderBy: {field: TAG_COMMIT_DATE, direction: DESC}, first: 10) { + nodes { name } + } + } + }`; + const variables = { + owner: owner, + name: repo, + }; + const tags = await github.graphql(query, variables); + let lastTag = tags.repository.refs.nodes[0].name; + let tagNumber = /\w+-(\d+)/.exec(lastTag)[1] | 0; + let channel = repo.split('-')[1]; + let newTag = `${channel}-${tagNumber + 1}`; + console.log(`New tag: ${newTag}`); + if (commit) { + let channelName = channel[0].toUpperCase() + channel.slice(1); + console.info(`Committing pending commit as ${channelName} #${tagNumber + 1}`); + await execa("git", ['commit', '-m', `${channelName} #${tagNumber + 1}`]); + } + console.info('Pushing tags to GitHub ...'); + await execa("git", ['tag', newTag]); + await execa("git", ['remote', 'add', 'target', `https://${altToken}@github.com/${owner}/${repo}.git`]); + await execa("git", ['push', 'target', 'master', '-f']); + await execa("git", ['push', 'target', 'master', '-f', '--tags']); + console.info('Successfully pushed new changes.'); +} + +async function generateReadme(pulls, context, mergeResults, execa) { + let baseUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/`; + let output = + "| Pull Request | Commit | Title | Author | Merged? |\n|----|----|----|----|----|\n"; + for (let pull of pulls) { + let pr = pull.number; + let result = mergeResults[pr]; + output += `| [${pr}](${baseUrl}/pull/${pr}) | [\`${result.rev || "N/A"}\`](${baseUrl}/pull/${pr}/files) | ${pull.title} | [${pull.author.login}](https://github.com/${pull.author.login}/) | ${result.success ? "Yes" : "No"} |\n`; + } + output += + "\n\nEnd of merge log. You can find the original README.md below the break.\n\n-----\n\n"; + output += fs.readFileSync("./README.md"); + fs.writeFileSync("./README.md", output); + await execa("git", ["add", "README.md"]); +} + +async function fetchPullRequests(pulls, repoUrl, execa) { + console.log("::group::Fetch pull requests"); + for (let pull of pulls) { + let pr = pull.number; + console.info(`Fetching PR ${pr} ...`); + await execa("git", [ + "fetch", + "-f", + "--no-recurse-submodules", + repoUrl, + `pull/${pr}/head:pr-${pr}`, + ]); + } + console.log("::endgroup::"); +} + +async function mergePullRequests(pulls, execa) { + let mergeResults = {}; + console.log("::group::Merge pull requests"); + await execa("git", ["config", "--global", "user.name", "citrabot"]); + await execa("git", [ + "config", + "--global", + "user.email", + "citra\x40citra-emu\x2eorg", // prevent email harvesters from scraping the address + ]); + let hasFailed = false; + for (let pull of pulls) { + let pr = pull.number; + console.info(`Merging PR ${pr} ...`); + try { + const process1 = execa("git", [ + "merge", + "--squash", + "--no-edit", + `pr-${pr}`, + ]); + process1.stdout.pipe(process.stdout); + await process1; + + const process2 = execa("git", ["commit", "-m", `Merge PR ${pr}`]); + process2.stdout.pipe(process.stdout); + await process2; + + const process3 = await execa("git", ["rev-parse", "--short", `pr-${pr}`]); + mergeResults[pr] = { + success: true, + rev: process3.stdout, + }; + } catch (err) { + console.log( + `::error title=#${pr} not merged::Failed to merge pull request: ${pr}: ${err}` + ); + mergeResults[pr] = { success: false }; + hasFailed = true; + await execa("git", ["reset", "--hard"]); + } + } + console.log("::endgroup::"); + if (hasFailed) { + throw 'There are merge failures. Aborting!'; + } + return mergeResults; +} + +async function mergebot(github, context, execa) { + const query = `query ($owner:String!, $name:String!, $label:String!) { + repository(name:$name, owner:$owner) { + pullRequests(labels: [$label], states: OPEN, first: 100) { + nodes { + number title author { login } + } + } + } + }`; + const variables = { + owner: context.repo.owner, + name: context.repo.repo, + label: "canary-merge", + }; + const result = await github.graphql(query, variables); + const pulls = result.repository.pullRequests.nodes; + let displayList = []; + for (let i = 0; i < pulls.length; i++) { + let pull = pulls[i]; + displayList.push({ PR: pull.number, Title: pull.title }); + } + console.info("The following pull requests will be merged:"); + console.table(displayList); + await fetchPullRequests(pulls, "https://github.com/citra-emu/citra", execa); + const mergeResults = await mergePullRequests(pulls, execa); + await generateReadme(pulls, context, mergeResults, execa); + await tagAndPush(github, context.repo.owner, `${context.repo.repo}-canary`, execa, true); +} + +module.exports.mergebot = mergebot; +module.exports.checkCanaryChanges = checkCanaryChanges; +module.exports.tagAndPush = tagAndPush; diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..c603603ed --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,99 @@ +name: citra-publish + +on: + schedule: + - cron: '7 0 * * *' + workflow_dispatch: + inputs: + nightly: + description: 'Whether trigger a nightly build (true/false/auto)' + required: false + default: 'true' + canary: + description: 'Whether trigger a canary build (true/false/auto)' + required: false + default: 'true' + +jobs: + nightly: + runs-on: ubuntu-latest + if: ${{ github.event.inputs.nightly != 'false' }} + steps: + - uses: actions/github-script@v5 + id: check-changes + name: 'Check for new changes' + with: + result-encoding: string + script: | + if (context.payload.inputs && context.payload.inputs.nightly === 'true') return true; + const delta = new Date() - new Date(context.payload.repository.pushed_at); + if (delta <= 86400000) { + return true; + } + console.log('No new changes detected.'); + return false; + # this checkout is required to make sure the GitHub Actions scripts are available + - uses: actions/checkout@v2 + if: ${{ steps.check-changes.outputs.result == 'true' }} + name: Pre-checkout + with: + submodules: false + - run: npm install execa@5 + if: ${{ steps.check-changes.outputs.result == 'true' }} + - uses: actions/checkout@v2 + name: Checkout + if: ${{ steps.check-changes.outputs.result == 'true' }} + with: + path: 'citra-merge' + fetch-depth: 0 + submodules: true + token: ${{ secrets.ALT_GITHUB_TOKEN }} + - uses: actions/github-script@v5 + name: 'Update and tag new commits' + if: ${{ steps.check-changes.outputs.result == 'true' }} + env: + ALT_GITHUB_TOKEN: ${{ secrets.ALT_GITHUB_TOKEN }} + with: + script: | + const execa = require("execa"); + const tagAndPush = require('./.github/workflows/ci-merge.js').tagAndPush; + process.chdir('${{ github.workspace }}/citra-merge'); + tagAndPush(github, context.repo.owner, `${context.repo.repo}-nightly`, execa); + canary: + runs-on: ubuntu-latest + if: ${{ github.event.inputs.canary != 'false' }} + steps: + # this checkout is required to make sure the GitHub Actions scripts are available + - uses: actions/checkout@v2 + name: Pre-checkout + with: + submodules: false + - uses: actions/github-script@v5 + id: check-changes + name: 'Check for new changes' + with: + script: | + if (context.payload.inputs && context.payload.inputs.canary === 'true') return true; + const checkCanaryChanges = require('./.github/workflows/ci-merge.js').checkCanaryChanges; + return checkCanaryChanges(github, context); + - run: npm install execa@5 + if: ${{ steps.check-changes.outputs.result == 'true' }} + - uses: actions/checkout@v2 + name: Checkout + if: ${{ steps.check-changes.outputs.result == 'true' }} + with: + path: 'citra-merge' + fetch-depth: 0 + submodules: true + token: ${{ secrets.ALT_GITHUB_TOKEN }} + - uses: actions/github-script@v5 + name: 'Check and merge canary changes' + if: ${{ steps.check-changes.outputs.result == 'true' }} + env: + ALT_GITHUB_TOKEN: ${{ secrets.ALT_GITHUB_TOKEN }} + with: + script: | + const execa = require("execa"); + const mergebot = require('./.github/workflows/ci-merge.js').mergebot; + process.chdir('${{ github.workspace }}/citra-merge'); + mergebot(github, context, execa);