From de768d12c169f5e28ea0be9a2bd7eab9a17485bb Mon Sep 17 00:00:00 2001 From: Louis Orleans Date: Thu, 31 Aug 2023 15:57:47 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=91=B7=20rewrite=20CI=20build=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-publish.yaml | 97 ++++++++------- .github/workflows/scripts/get-image-tags.rb | 30 +++++ .github/workflows/scripts/lib.rb | 55 +++++++++ .../scripts/test/get-image-tags.unit.rb | 112 ++++++++++++++++++ 4 files changed, 248 insertions(+), 46 deletions(-) create mode 100755 .github/workflows/scripts/get-image-tags.rb create mode 100644 .github/workflows/scripts/lib.rb create mode 100755 .github/workflows/scripts/test/get-image-tags.unit.rb diff --git a/.github/workflows/build-publish.yaml b/.github/workflows/build-publish.yaml index cb30400..1425ffa 100644 --- a/.github/workflows/build-publish.yaml +++ b/.github/workflows/build-publish.yaml @@ -1,67 +1,72 @@ -name: 'Publish Container Image' +name: 'Build & Publish Container Image' -# Controls when the action will run. Triggers the workflow on push or pull -# request events but only for the master branch on: push: - branches: - - '*' + pull_request: permissions: contents: 'read' - packages: 'write' -# A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: - # This workflow contains a single job called "build" - build-and-publish: - # The type of runner that the job will run on + test-scripts: runs-on: 'ubuntu-latest' - - # Steps represent a sequence of tasks that will be executed as part of the job steps: - name: 'Checkout Repository ๐Ÿ›Ž๏ธ' - uses: 'actions/checkout@v2' + uses: 'actions/checkout@v3' + - name: 'Test ๐Ÿงช' + run: 'ruby .github/workflows/scripts/test/get-image-tags.unit.rb' - - name: 'Build ๐Ÿ—๏ธ' - id: 'build' + container-build: + needs: 'test-scripts' + runs-on: 'ubuntu-latest' + steps: + - name: 'Checkout Repository ๐Ÿ›Ž๏ธ' + uses: 'actions/checkout@v3' + - uses: 'docker/setup-buildx-action@v2' + + - name: 'Get image tag names ๐Ÿท๏ธ' + id: 'tag-image' run: | - image="$( \ - basename "$(echo "${{ github.repository }}")" \ - | tr '[:upper:]' '[:lower:]' \ - | sed 's/docker-//' \ - )" - echo "image=$image" >> "$GITHUB_OUTPUT" + echo "image_tags=$( + .github/workflows/scripts/get-image-tags.rb \ + "${{ github.repository }}" \ + "${{ github.ref_name }}" \ + "${{ github.ref_type }}" \ + "${{ github.event.repository.default_branch }}" \ + )" >> "$GITHUB_OUTPUT" + - name: 'Build container ๐Ÿณ' + uses: 'docker/build-push-action@v3' + with: + file: 'Dockerfile' + tags: '${{ steps.tag-image.outputs.image_tags }}' + cache-from: 'type=gha' + cache-to: 'type=gha,mode=max' - docker build --file "Dockerfile" --tag "$image" . + outputs: + image_tags: '${{ steps.tag-image.outputs.image_tags }}' + publish: + needs: + - 'container-build' + if: contains(needs.container-build.outputs.image_tags, ':latest') + runs-on: 'ubuntu-latest' + permissions: + packages: 'write' + steps: + - name: 'Checkout Repository ๐Ÿ›Ž๏ธ' + uses: 'actions/checkout@v3' + - uses: 'docker/setup-buildx-action@v2' - name: 'Login to GitHub Container Registry ๐Ÿ”‘' - uses: 'docker/login-action@v1' - if: '${{ github.ref_name }} == ${{ github.event.repository.default_branch }}' + uses: 'docker/login-action@v2' with: registry: 'ghcr.io' username: '${{ github.repository_owner }}' password: '${{ secrets.GITHUB_TOKEN }}' - - name: 'Publish to Registry ๐Ÿณ' - if: '${{ github.ref_name }} == ${{ github.event.repository.default_branch }}' - run: | - image="${{ steps.build.outputs.image }}" - repo="ghcr.io/${{ github.repository_owner }}/$image" - branch="$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')" - version="$branch" - - echo "repo = $repo" - echo "image = $image" - echo "version = $version" - - docker tag "$image" "$repo/$image:$version" - docker push "$repo/$image:$version" - - # Use Docker `latest` tag convention - if [[ "${{ github.event.repository.default_branch }}" == "$branch" ]]; then - docker tag "$image" "$repo/$image:latest" - docker push "$repo/$image:latest" - fi - - echo "registry_uri=$repo/$image" >> "$GITHUB_OUTPUT" + - name: 'Publish to Registry ๐Ÿ’จ' + uses: 'docker/build-push-action@v3' + with: + file: 'Dockerfile' + push: true + tags: '${{ needs.container-build.outputs.image_tags }}' + cache-from: 'type=gha' diff --git a/.github/workflows/scripts/get-image-tags.rb b/.github/workflows/scripts/get-image-tags.rb new file mode 100755 index 0000000..bdcb230 --- /dev/null +++ b/.github/workflows/scripts/get-image-tags.rb @@ -0,0 +1,30 @@ +#!/usr/bin/env ruby + +require 'json' +require_relative 'lib' + +def main + git_repo = ARGV[0] + git_ref_name = ARGV[1] + git_ref_type = ARGV[2] + git_default_branch = ARGV[3] + + # log to stderr so that stdout only contains the full tags + $stderr.puts "'#{git_repo}', '#{git_ref_name}', '#{git_ref_type}', '#{git_default_branch}'" + + tags = + get_image_tags( + git_repo: git_repo, + git_ref_name: git_ref_name, + git_ref_type: git_ref_type, + git_default_branch: git_default_branch, + package: JSON.parse(File.read('package.json')), + ).to_a.join(',') + + # log to stderr so that stdout only contains the full tags + $stderr.puts tags + + puts tags +end + +main() diff --git a/.github/workflows/scripts/lib.rb b/.github/workflows/scripts/lib.rb new file mode 100644 index 0000000..d8d0353 --- /dev/null +++ b/.github/workflows/scripts/lib.rb @@ -0,0 +1,55 @@ +require 'set' + +# @param git_repo [String] +# @param git_ref_name [String] +# @param git_ref_type [String] +# @param git_default_branch [String] +# @return [Set[String]] +def get_image_tags( + git_repo: nil, + git_ref_name: nil, + git_ref_type: nil, + git_default_branch: nil, + package: nil +) + container_repo = "ghcr.io/#{git_repo.downcase}" + versions = Set[] + + if git_ref_type == 'branch' + # add safe branch name + versions.add(git_ref_name.downcase.gsub(/[^a-z0-9._\n]+/, '-')) + elsif git_ref_type == 'tag' + # add version tag + versions.add(package['version']) + # TODO: check that this is actually latest + parsed = parse_semver(package['version']) + if parsed.pre == nil + versions.add(parsed.major) + versions.add("#{parsed.major}.#{parsed.minor}") + versions.add("#{parsed.major}.#{parsed.minor}.#{parsed.patch}") + end + + # TODO: if the tag was made on a non-default branch, we still tag with default branch + versions.add(git_default_branch) + end + + # TODO: if `tag`, check that this is actually latest + if git_ref_name == git_default_branch or git_ref_type == 'tag' + # Use Docker `latest` tag convention, only tagging `latest` on default branch. + versions.add('latest') + end + + return versions.map! { |v| "#{container_repo}/bot:#{v}" } +end + +Semver = Struct.new('Semver', :major, :minor, :patch, :pre, :build) + +# @param version [String] +# @return [Semver] +def parse_semver(version) + # Ruby extracts regex named groups to local vars (but only if the regex is inlined). + /^(?\d+)\.(?\d+)\.(?\d+)(?:-(?
[0-9A-Za-z\-.]+))?(?:\+(?[0-9A-Za-z\-]+))?$/ =~
+    version
+
+  Semver.new(major.to_i, minor.to_i, patch.to_i, pre, build)
+end
diff --git a/.github/workflows/scripts/test/get-image-tags.unit.rb b/.github/workflows/scripts/test/get-image-tags.unit.rb
new file mode 100755
index 0000000..32e69b9
--- /dev/null
+++ b/.github/workflows/scripts/test/get-image-tags.unit.rb
@@ -0,0 +1,112 @@
+require 'test/unit'
+require 'json'
+require 'set'
+
+require_relative '../lib'
+
+class TestGetImageTags < Test::Unit::TestCase
+  def test_simple_branch
+    assert_equal(
+      Set['ghcr.io/virginity-bot/virginity-bot/bot:feat-foo-bar'],
+      get_image_tags(
+        git_repo: 'Virginity-Bot/virginity-bot',
+        git_ref_name: 'feat/foo-bar',
+        git_ref_type: 'branch',
+        git_default_branch: 'master',
+        package: JSON.parse('{"version": "1.0.0"}'),
+      ),
+    )
+
+    assert_equal(
+      Set[
+        'ghcr.io/virginity-bot/virginity.bot/bot:latest',
+        'ghcr.io/virginity-bot/virginity.bot/bot:master'
+      ],
+      get_image_tags(
+        git_repo: 'Virginity-Bot/virginity.bot',
+        git_ref_name: 'master',
+        git_ref_type: 'branch',
+        git_default_branch: 'master',
+        package: JSON.parse('{"version": "1.0.0"}'),
+      ),
+    )
+  end
+
+  def test_simple_tag
+    assert_equal(
+      Set[
+        'ghcr.io/virginity-bot/virginity.bot/bot:latest',
+        'ghcr.io/virginity-bot/virginity.bot/bot:master',
+        'ghcr.io/virginity-bot/virginity.bot/bot:1.0.0',
+        'ghcr.io/virginity-bot/virginity.bot/bot:1.0',
+        'ghcr.io/virginity-bot/virginity.bot/bot:1'
+      ],
+      get_image_tags(
+        git_repo: 'Virginity-Bot/virginity.bot',
+        git_ref_name: '1.0.0',
+        git_ref_type: 'tag',
+        git_default_branch: 'master',
+        package: JSON.parse('{"version": "1.0.0"}'),
+      ),
+    )
+  end
+
+  def test_pre_tag
+    assert_equal(
+      Set[
+        'ghcr.io/virginity-bot/virginity.bot/bot:latest',
+        'ghcr.io/virginity-bot/virginity.bot/bot:master',
+        'ghcr.io/virginity-bot/virginity.bot/bot:1.0.0-pre'
+      ],
+      get_image_tags(
+        git_repo: 'Virginity-Bot/virginity.bot',
+        git_ref_name: '1.0.0',
+        git_ref_type: 'tag',
+        git_default_branch: 'master',
+        package: JSON.parse('{"version": "1.0.0-pre"}'),
+      ),
+    )
+  end
+
+  def test_unsafe_branch_name
+    assert_equal(
+      Set['ghcr.io/virginity-bot/virginity.bot/bot:feat-foo-bar'],
+      get_image_tags(
+        git_repo: 'Virginity-Bot/virginity.bot',
+        git_ref_name: 'feat/Foo---bar',
+        git_ref_type: 'branch',
+        git_default_branch: 'master',
+        package: JSON.parse('{"version": "1.0.0"}'),
+      ),
+    )
+  end
+end
+
+class TestParseSemver < Test::Unit::TestCase
+  def test_parse_basic
+    parsed = parse_semver('1.2.3')
+    assert_equal(1, parsed.major)
+    assert_equal(2, parsed.minor)
+    assert_equal(3, parsed.patch)
+    assert_equal(nil, parsed.pre)
+    assert_equal(nil, parsed.build)
+  end
+
+  def test_parse_pre
+    parsed = parse_semver('1.2.3-p.re')
+    assert_equal(1, parsed.major)
+    assert_equal(2, parsed.minor)
+    assert_equal(3, parsed.patch)
+    assert_equal('p.re', parsed.pre)
+    assert_equal(nil, parsed.build)
+  end
+
+  def test_parse_full_semver
+    parsed = parse_semver('1.2.3-p.re+build')
+    assert_equal(1, parsed.major)
+    assert_equal(2, parsed.minor)
+    assert_equal(3, parsed.patch)
+    assert_equal('p.re', parsed.pre)
+    assert_equal('build', parsed.build)
+  end
+end