From 14ea16e499e08281d683b7b3db87f6d45aa64098 Mon Sep 17 00:00:00 2001 From: liushuyu Date: Sat, 10 Jun 2023 21:51:45 -0600 Subject: [PATCH] CI: add auto-publishing steps for Android --- .github/workflows/android-merge.js | 218 ++++++++++++++++++++++++++ .github/workflows/android-publish.yml | 57 +++++++ 2 files changed, 275 insertions(+) create mode 100644 .github/workflows/android-merge.js create mode 100644 .github/workflows/android-publish.yml diff --git a/.github/workflows/android-merge.js b/.github/workflows/android-merge.js new file mode 100644 index 000000000..7e02dc9e5 --- /dev/null +++ b/.github/workflows/android-merge.js @@ -0,0 +1,218 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +// Note: This is a GitHub Actions script +// It is not meant to be executed directly on your machine without modifications + +const fs = require("fs"); +// which label to check for changes +const CHANGE_LABEL = 'android-merge'; +// how far back in time should we consider the changes are "recent"? (default: 24 hours) +const DETECTION_TIME_FRAME = (parseInt(process.env.DETECTION_TIME_FRAME)) || (24 * 3600 * 1000); + +async function checkBaseChanges(github, context) { + // query the commit date of the latest commit on this branch + const query = `query($owner:String!, $name:String!, $ref:String!) { + repository(name:$name, owner:$owner) { + ref(qualifiedName:$ref) { + target { + ... on Commit { id pushedDate oid } + } + } + } + }`; + const variables = { + owner: context.repo.owner, + name: context.repo.repo, + ref: 'refs/heads/master', + }; + const result = await github.graphql(query, variables); + const pushedAt = result.repository.ref.target.pushedDate; + console.log(`Last commit pushed at ${pushedAt}.`); + const delta = new Date() - new Date(pushedAt); + if (delta <= DETECTION_TIME_FRAME) { + console.info('New changes detected, triggering a new build.'); + return true; + } + console.info('No new changes detected.'); + return false; +} + +async function checkAndroidChanges(github, context) { + if (checkBaseChanges(github, context)) 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: CHANGE_LABEL, + }; + 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) <= DETECTION_TIME_FRAME) { + 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); + const tagList = tags.repository.refs.nodes; + const lastTag = tagList[0] ? tagList[0].name : 'dummy-0'; + const tagNumber = /\w+-(\d+)/.exec(lastTag)[1] | 0; + const channel = repo.split('-')[1]; + const 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', '--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", "yuzubot"]); + await execa("git", [ + "config", + "--global", + "user.email", + "yuzu\x40yuzu-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: CHANGE_LABEL, + }; + 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/yuzu-emu/yuzu", execa); + const mergeResults = await mergePullRequests(pulls, execa); + await generateReadme(pulls, context, mergeResults, execa); + await tagAndPush(github, context.repo.owner, `${context.repo.repo}-android`, execa, true); +} + +module.exports.mergebot = mergebot; +module.exports.checkAndroidChanges = checkAndroidChanges; +module.exports.tagAndPush = tagAndPush; +module.exports.checkBaseChanges = checkBaseChanges; diff --git a/.github/workflows/android-publish.yml b/.github/workflows/android-publish.yml new file mode 100644 index 000000000..8f46fcf74 --- /dev/null +++ b/.github/workflows/android-publish.yml @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: 2023 yuzu Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later + +name: yuzu-android-publish + +on: + schedule: + - cron: '37 0 * * *' + workflow_dispatch: + inputs: + android: + description: 'Whether to trigger an Android build (true/false/auto)' + required: false + default: 'true' + +jobs: + android: + runs-on: ubuntu-latest + if: ${{ github.event.inputs.android != 'false' && github.repository == 'yuzu-emu/yuzu' }} + steps: + # this checkout is required to make sure the GitHub Actions scripts are available + - uses: actions/checkout@v3 + name: Pre-checkout + with: + submodules: false + - uses: actions/github-script@v6 + id: check-changes + name: 'Check for new changes' + env: + # 24 hours + DETECTION_TIME_FRAME: 86400000 + with: + script: | + if (context.payload.inputs && context.payload.inputs.android === 'true') return true; + const checkAndroidChanges = require('./.github/workflows/android-merge.js').checkAndroidChanges; + return checkAndroidChanges(github, context); + - run: npm install execa@5 + if: ${{ steps.check-changes.outputs.result == 'true' }} + - uses: actions/checkout@v3 + name: Checkout + if: ${{ steps.check-changes.outputs.result == 'true' }} + with: + path: 'yuzu-merge' + fetch-depth: 0 + submodules: true + token: ${{ secrets.ALT_GITHUB_TOKEN }} + - uses: actions/github-script@v5 + name: 'Check and merge Android 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/android-merge.js').mergebot; + process.chdir('${{ github.workspace }}/yuzu-merge'); + mergebot(github, context, execa);