From 1f836fed440e056755b9003975a0604e428cd925 Mon Sep 17 00:00:00 2001 From: Steve Boyd Date: Wed, 19 Jul 2023 17:21:28 +1200 Subject: [PATCH] NEW Create action --- .github/workflows/auto-tag.yml | 12 ++ .github/workflows/ci.yml | 26 ++++ .gitignore | 2 + LICENSE | 29 ++++ README.md | 16 +- action.yml | 200 +++++++++++++++++++++++++ branches.php | 9 ++ funcs.php | 128 ++++++++++++++++ phpunit.xml | 8 + tests/BranchesTest.php | 263 +++++++++++++++++++++++++++++++++ tests/bootstrap.php | 3 + 11 files changed, 694 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/auto-tag.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 action.yml create mode 100644 branches.php create mode 100644 funcs.php create mode 100644 phpunit.xml create mode 100644 tests/BranchesTest.php create mode 100644 tests/bootstrap.php diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml new file mode 100644 index 0000000..17712c8 --- /dev/null +++ b/.github/workflows/auto-tag.yml @@ -0,0 +1,12 @@ +name: Auto-tag +on: + push: + tags: + - '*.*.*' +jobs: + auto-tag: + name: Auto-tag + runs-on: ubuntu-latest + steps: + - name: Auto-tag + uses: silverstripe/gha-auto-tag@v1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e635dfc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + ci: + name: CI + runs-on: ubuntu-latest + steps: + + - name: Checkout code + uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + + - name: Install PHP + uses: shivammathur/setup-php@1a18b2267f80291a81ca1d33e7c851fe09e7dfc4 # v2.22.0 + with: + php-version: 8.1 + + - name: Install PHPUnit + run: wget https://phar.phpunit.de/phpunit-9.5.phar + + - name: PHPUnit + run: php phpunit-9.5.phar --verbose --colors=always diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81fdeb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.json +.phpunit.result.cache diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..82361bc --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2023, SilverStripe Limited - www.silverstripe.com +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 7b8019f..a3734fd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,14 @@ -# gha-merge-up -GitHub Action to merge-up supported branches in a repository +# GitHub Actions - Merge-up + +Merge-up supported branches in a repository + +## Usage + +**.github/workflows/merge-up.yml** +```yml +steps: + - name: Merge-up + uses: silverstripe/gha-merge-up@v1 +``` + +This action has no inputs diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..6508317 --- /dev/null +++ b/action.yml @@ -0,0 +1,200 @@ +name: Merge up +description: GitHub Action to merge-up supported branches in a repository + +runs: + using: composite + steps: + + - name: Install PHP + uses: shivammathur/setup-php@1a18b2267f80291a81ca1d33e7c851fe09e7dfc4 # v2.22.0 + with: + php-version: '8.1' + + - name: Determine if should merge-up + id: determine + shell: bash + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_REF_NAME: ${{ github.ref_name }} + run: | + # The minimum cms major with commercial support - configured at a global level + # Change this when major version support changes + MINIMUM_CMS_MAJOR=4 + + # Get the default branch from GitHub API + # We need to make an API call rather than just assume that the current branch is the default + # because this workflow may be triggered by workflow_dispatch on any branch + # https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#get-a-repository + RESP_CODE=$(curl -w %{http_code} -s -o __base.json \ + -X GET "https://api.github.com/repos/$GITHUB_REPOSITORY" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ github.token }}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + ) + if [[ $RESP_CODE != "200" ]]; then + echo "Unable to fetch repository meta data - HTTP response code was $RESP_CODE" + exit 1 + fi + + DEFAULT_BRANCH=$(jq -r .default_branch __base.json) + echo "DEFAULT_BRANCH is $DEFAULT_BRANCH" + rm __base.json + + if [[ $DEFAULT_BRANCH != $GITHUB_REF_NAME ]]; then + echo "Current branch $GITHUB_REF_NAME is not the same as default branch $DEFAULT_BRANCH" + exit 1 + fi + + # Gets all branches from GitHub API + # https://docs.github.com/en/rest/branches/branches?apiVersion=2022-11-28#list-branches + RESP_CODE=$(curl -w %{http_code} -s -o __branches.json \ + -X GET "https://api.github.com/repos/$GITHUB_REPOSITORY/branches?per_page=100" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ github.token }}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + ) + if [[ $RESP_CODE != "200" ]]; then + echo "Unable to read list of branches - HTTP response code was $RESP_CODE" + exit 1 + fi + + # Gets 100 most recently created tags from GitHub API + # https://docs.github.com/en/rest/git/tags?apiVersion=2022-11-28 + RESP_CODE=$(curl -w %{http_code} -s -o __tags.json \ + -X GET "https://api.github.com/repos/$GITHUB_REPOSITORY/tags?per_page=100" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ github.token }}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + ) + if [[ $RESP_CODE != "200" ]]; then + echo "Unable to read list of tags - HTTP response code was $RESP_CODE" + exit 1 + fi + + # Download composer.json for use in branches.php + curl -s -o __composer.json https://raw.githubusercontent.com/$GITHUB_REPOSITORY/$DEFAULT_BRANCH/composer.json + + BRANCHES=$(MINIMUM_CMS_MAJOR=$MINIMUM_CMS_MAJOR DEFAULT_BRANCH=$DEFAULT_BRANCH php ${{ github.action_path }}/branches.php) + echo "BRANCHES is $BRANCHES" + if [[ $BRANCHES =~ "^FAILURE \- (.+)$" ]]; then + MESSAGE=${BASH_REMATCH[1]} + echo "Exception in branches.php - $MESSAGE" + exit 1 + fi + if [[ $BRANCHES == "" ]]; then + echo "No branches to merge-up" + exit 0 + fi + echo "branches=$BRANCHES" >> $GITHUB_OUTPUT + rm __tags.json + rm __branches.json + rm __composer.json + + # Check to see if there is anything to merge-up using the GitHub API + # Another approach is to see if we should merged using git, however that approach requires us to + # first checkout the entire git history for the repo first which can use a lot of data, and there may be + # some hidden data rate-limit in GitHub that we don't know about + # These API calls are fast so it really doesn't add much overhead + # One downside to the API approach is that we will abort early and not merge-up anything when we may have been + # able to say merge-up 4.13 -> 4 but not 4 -> 5.0 + FROM_BRANCH="" + INTO_BRANCH="" + for BRANCH in $BRANCHES; do + FROM_BRANCH=$INTO_BRANCH + INTO_BRANCH=$BRANCH + if [[ $FROM_BRANCH == "" ]]; then + continue + fi + # https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#compare-two-commits + RESP_CODE=$(curl -w %{http_code} -s -o __compare.json \ + -X GET "https://api.github.com/repos/$GITHUB_REPOSITORY/compare/$INTO_BRANCH...$FROM_BRANCH" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ github.token }}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + ) + if [[ $RESP_CODE != "200" ]]; then + echo "Unable to compare branches - HTTP response code was $RESP_CODE" + exit 1 + fi + FILES=$(jq -r .files[].filename __compare.json) + rm __compare.json + + # Don't allow merge-ups when there are changes in dependency files + DEPENDENCY_FILES="composer.json package.json yarn.lock" + for DEPENDENCY_FILE in $DEPENDENCY_FILES; do + if [[ $(echo "$FILES" | grep $DEPENDENCY_FILE) != "" ]]; then + echo "Unable to mergeup between $FROM_BRANCH and $INTO_BRANCH - there are changes in $DEPENDENCY_FILE" + exit 1 + fi + done + + # Don't allow merge-ups when there are JS changes that would require a yarn build + if [[ $(echo "$FILES" | grep client/dist) != "" ]]; then + echo "Unable to mergeup between $FROM_BRANCH and $INTO_BRANCH - there are changes to JS dist files" + exit 1 + fi + done + + # actions/checkout with fetch-depth: 0 will fetch ALL git history for the repository + # this is required for a merge-up to ensure that nothing is missed + - name: Checkout code + uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + with: + fetch-depth: 0 + + - name: Git merge-up + shell: bash + env: + BRANCHES: ${{ steps.determine.outputs.branches }} + run: | + # Set git user to github-actions bot + # The 41898282+ email prefixed is the required, matches the ID here + # https://api.github.com/users/github-actions%5Bbot%5D + # https://github.community/t/github-actions-bot-email-address/17204/6 + git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions" + + FROM_BRANCH="" + INTO_BRANCH="" + for BRANCH in $BRANCHES; do + FROM_BRANCH=$INTO_BRANCH + INTO_BRANCH=$BRANCH + if [[ $FROM_BRANCH == "" ]]; then + continue + fi + echo "Attempting to merge-up $FROM_BRANCH into $INTO_BRANCH" + + # Checkout both branches to ensure branch info is up to date + git checkout $FROM_BRANCH + git checkout $INTO_BRANCH + + # Perform the merge-up + git merge --no-ff --no-commit $FROM_BRANCH + + # Check for merge conflicts - this is just an additional check that is probably + # not required as git seems like it does the equivalent of exit 1 when it + # detects a merge conflict. Still it doesn't hurt to be extra cautious. + GIT_STATUS=$(git status) + if [[ "$GIT_STATUS" =~ 'Changes not staged for commit' ]]; then + echo "Merge conflict found when merging-up $FROM_BRANCH into $INTO_BRANCH. Aborting." + exit 1 + fi + + # Check for any random files that shouldn't be committed + if [[ "$GIT_STATUS" =~ 'Untracked files' ]]; then + echo "Untracked files found when merging-up $FROM_BRANCH into $INTO_BRANCH. Aborting." + exit 1 + fi + + # Continue if there's nothing to commit + if [[ "$GIT_STATUS" =~ 'nothing to commit, working tree clean' ]]; then + echo "No changes found when merging-up $FROM_BRANCH into $INTO_BRANCH. Skipping." + continue + fi + + # Commit and push the merge-up + # --no-edit in the context of a merge commit uses the default auto-generated commit message. + git commit --no-edit + git push origin $INTO_BRANCH + echo "Succesfully merged-up $FROM_BRANCH into $INTO_BRANCH" + done diff --git a/branches.php b/branches.php new file mode 100644 index 0000000..25b5844 --- /dev/null +++ b/branches.php @@ -0,0 +1,9 @@ +require->{'silverstripe/framework'} ?? ''); + if (!$version) { + $version = preg_replace('#[^0-9\.]#', '', $json->require->{'silverstripe/cms'} ?? ''); + } + if (!$version) { + $version = preg_replace('#[^0-9\.]#', '', $json->require->{'silverstripe/mfa'} ?? ''); + } + if (!$version) { + $version = preg_replace('#[^0-9\.]#', '', $json->require->{'silverstripe/assets'} ?? ''); + $matchedOnBranchThreeLess = true; + } + if (preg_match('#^([0-9]+)+\.?[0-9]*$#', $version, $matches)) { + $defaultCmsMajor = $matches[1]; + if ($matchedOnBranchThreeLess) { + $defaultCmsMajor += 3; + } + } else { + $phpVersion = $json->require->{'php'} ?? ''; + if (substr($phpVersion,0, 4) === '^7.4') { + $defaultCmsMajor = 4; + } elseif (substr($phpVersion,0, 4) === '^8.1') { + $defaultCmsMajor = 5; + } + } + if ($defaultCmsMajor === '') { + throw new Exception('Could not work out what the default CMS major version this module uses'); + } + // work out major diff e.g for silverstripe/admin for CMS 5 => 5 - 2 = 3 + $majorDiff = $defaultCmsMajor - $defaultMajor; + + $minorsWithStableTags = []; + $contents = $tagsJson ?: file_get_contents('__tags.json'); + foreach (json_decode($contents) as $row) { + $tag = $row->name; + if (!preg_match('#^([0-9]+)\.([0-9]+)\.([0-9]+)$#', $tag, $matches)) { + continue; + } + $major = $matches[1]; + $minor = $major. '.' . $matches[2]; + $minorsWithStableTags[$major][$minor] = true; + } + + $branches = []; + $contents = $branchesJson ?: file_get_contents('__branches.json'); + foreach (json_decode($contents) as $row) { + $branch = $row->name; + // filter out non-standard branches + if (!preg_match('#^([0-9]+)+\.?[0-9]*$#', $branch, $matches)) { + continue; + } + // filter out majors that are too old + $major = $matches[1]; + if (($major + $majorDiff) < $minimumCmsMajor) { + continue; + } + // suffix a temporary .999 minor version to major branches so that it's sorted correctly later + if (preg_match('#^[0-9]+$#', $branch)) { + $branch .= '.999'; + } + $branches[] = $branch; + } + + // sort so that newest is first + usort($branches, 'version_compare'); + $branches = array_reverse($branches); + + // remove the temporary .999 + array_walk($branches, function(&$branch) { + $branch = preg_replace('#\.999$#', '', $branch); + }); + + // remove all branches except: + // - the latest major branch in each release line + // - the latest minor branch with a stable tag in each release line + // - any minor branches without stable tags with a higher minor version than the latest minor with a stable tag + $foundMinorInMajor = []; + $foundMinorBranchWithStableTag = []; + foreach ($branches as $i => $branch) { + // only remove minor branches, leave major branches in + if (!preg_match('#^([0-9]+)\.[0-9]+$#', $branch, $matches)) { + continue; + } + $major = $matches[1]; + if (isset($foundMinorBranchWithStableTag[$major]) && isset($foundMinorInMajor[$major])) { + unset($branches[$i]); + continue; + } + if (isset($minorsWithStableTags[$major][$branch])) { + $foundMinorBranchWithStableTag[$major] = true; + } + $foundMinorInMajor[$major] = true; + } + + // reverse the array so that oldest is first + $branches = array_reverse($branches); + + return $branches; +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..f5e111b --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,8 @@ + + + + + tests + + + diff --git a/tests/BranchesTest.php b/tests/BranchesTest.php new file mode 100644 index 0000000..8e79a52 --- /dev/null +++ b/tests/BranchesTest.php @@ -0,0 +1,263 @@ +assertSame($expected, $actual); + } + + public function provideBranches() + { + return [ + '5.1.0-beta1, CMS 6 branch detected on silverstripe/framework' => [ + 'expected' => ['4.13', '4', '5.0', '5.1', '5', '6'], + 'defaultBranch' => '5', + 'minimumCmsMajor' => '4', + 'composerJson' => << << << [ + 'expected' => ['4.13', '4', '5.1', '5'], + 'defaultBranch' => '5', + 'minimumCmsMajor' => '4', + 'composerJson' => << << << [ + 'expected' => ['4.13', '4', '5.1', '5'], + 'defaultBranch' => '5', + 'minimumCmsMajor' => '4', + 'composerJson' => << << << [ + 'expected' => ['4.13', '4', '5.1', '5'], + 'defaultBranch' => '5', + 'minimumCmsMajor' => '4', + 'composerJson' => << << << [ + 'expected' => ['1.13', '2.0', '2.1', '2'], + 'defaultBranch' => '2', + 'minimumCmsMajor' => '4', + 'composerJson' => << << << [ + 'expected' => ['1.13', '1', '2.1', '2.2', '2.3', '2'], + 'defaultBranch' => '2', + 'minimumCmsMajor' => '4', + 'composerJson' => << << << [ + 'expected' => ['5.9', '5', '6.0', '6', '7'], + 'defaultBranch' => '5', // this repo has a `5` branch for CMS 4 and a '6' branch for CMS 5 + 'minimumCmsMajor' => '4', + 'composerJson' => << << <<