ruby setup + github api approach + secret handling

This commit is contained in:
amrocha
2019-06-03 12:30:38 -07:00
parent 26499bac14
commit 802f69b00c
18 changed files with 517 additions and 16 deletions

6
.shopify-build/create-branch Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env ruby
require 'bundler/setup'
require_relative './create_test_branch'
branch_creator = DevelopmentSupport::CreateTestBranch.new
exit(branch_creator.create_branch)

View File

@@ -0,0 +1,40 @@
# frozen_string_literal: true
require_relative './github/github_helper'
module DevelopmentSupport
class CreateTestBranch
def initialize(repository_name = 'Shopify/polaris-styleguide',
branch = "testing-alpha-test-branch")
@github_helper = Github::GitHubHelper.new(repository_name)
@branch = branch
end
def create_branch
puts 'Now committing the changes on a new branch.'
branch = @github_helper.change_files(
@branch,
{
update: ['./../package.json', './../yarn.lock']
},
commit_message,
)
if branch
puts "Branch created"
else
puts "Error creating branch"
return 1
end
end
private
def commit_message
<<~EOF
Update Polaris to test version
This is an automated commit
EOF
end
end
end

View File

@@ -1,3 +0,0 @@
#!/usr/bin/env bash
echo "IMPLEMENT ME"

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
shopt -s extglob
set -e
echo "@shopify:registry=https://packages.shopify.io/shopify/node/npm/" > '.npmrc'

View File

@@ -0,0 +1,28 @@
# typed: true
# frozen_string_literal: true
require 'octokit'
require_relative './../jwt_helper'
module DevelopmentSupport
module Github
# This client uses Octokit to access the GitHub APIs.
# Authentication to the GitHub APIs is done through GitHub Integrations.
#
# For more information:
# * Shopify + GitHub Integrations: https://github.com/Shopify/vendor-github#github-integrations
# * Octokit Source: https://github.com/octokit/octokit.rb
# * GitHub APIs: https://developer.github.com/v3/
class APIClient
# GitHub Shopify Core Installation ID
INSTALLATION_ID = 54319
# Creates an installation access token, expires after 1 hour
def self.integration_client(pem = ENV['GITHUB_PEM'])
client = Octokit::Client.new(bearer_token: DevelopmentSupport::JWTHelper.new_jwt_token(pem))
resource = client.create_installation_access_token(INSTALLATION_ID).to_h
client.access_token = resource.fetch(:token)
client
end
end
end
end

View File

@@ -0,0 +1,218 @@
# typed: true
# frozen_string_literal: true
require_relative 'api_client'
require_relative './tree'
module DevelopmentSupport
module Github
# For local development:
#
# Tests are recorded using VCR, to record tests please use the GitHub PEM
# found in the Prod Eng Github 1Password Vault.
#
# You can download the PEM, place it in the rails root (ie. shopify/ ) and then run tests with:
# RECORD_TESTS=1 dev t test/unit/lib/development_support/github/github_helper_test.rb
#
# After recording the tests, be sure to delete the first recorded request
# including the access token for the GitHub API.
class GitHubHelper
BASE_BRANCH = 'master'
# Creates a GitHub API helper for a given repository and obtains an API access token
# This class uses Octokit with the shopify core dev-ci GitHub integration
# @see https://github.com/octokit/octokit.rb
#
# @param repository [String] the name of the Shopify repository
# @example Create a helper for Shopify core
# github_helper = DevelopmentSupport::GitHubHelper.new('Shopify/shopify')
def initialize(repository)
@repo = repository
@github_client = Github::APIClient.integration_client
end
# Formats filenames, checks preconditions and then calls commit_changes_and_create_pr to delete or update files
#
# @param branch [String] name of non master branch that is to contain these changes
# @param filenames [Hash] a hash with key :update and :delete mapping to an array of filenames
# NOTE: provided filenames must be relative to the project directory and joined by File.join()
# @param commit_message [String] the commit message for the changes
def change_files(branch, filenames, commit_message, pr_content)
update_files = filenames.fetch(:update, [])
delete_files = filenames.fetch(:delete, [])
# Group every file by its directory
# ['db/migrate/migration1.rb', 'db/migrate/migration2', 'db/lhm/lhm1.rb', 'db/lhm/lhm2.rb']
# {
# 'db/migrate/' => ['migration1.rb', 'migration2.rb'],
# 'db/lhm/' => ['lhm1.rb', 'lhm2.rb']
# }
grouped_update_files = group_files_by_dirname(update_files)
grouped_deleted_files = group_files_by_dirname(delete_files)
# is there anything to change? we dont want to create an empty commit and PR.
return nil if grouped_deleted_files.empty? && !has_changes?(grouped_update_files)
# merge the two hashes of grouped files, if a collision occurs, merge their array values as well
changing_files = grouped_update_files.merge(grouped_deleted_files) do |_, update_names, delete_names|
update_names + delete_names
end
commit_changes_and_create_pr(branch, changing_files, commit_message, pr_content) do |current_path, tree_content|
# if there are files to be updated
if grouped_update_files.key?(current_path)
tree_content.update_files(grouped_update_files[current_path]) do |node|
@github_client.create_blob(@repo, File.read("#{current_path}/#{node.path}"))
end
end
# if there are files to be deleted
if grouped_deleted_files.key?(current_path)
tree_content.delete_files(grouped_deleted_files[current_path])
end
end
end
# Creates a branch, modifies a list of files from a directory, commits and creates a PR.
#
# @param branch_name [String] name of non master branch that is to contain these changes
# @param files [Hash] A hash mapping the path of the file directory to the names of the files
# @example { 'path/to/folder' => ['file1.rb', 'file2.rb'] }
# @param commit_message [String] commit message for the deleted files
# @param pr_content [Hash] A hash containing a 'title' and 'body' key used for the pr title and body
# @return [Sawyer::Resource] A hash representing the generated pull request
# @example delete_files_commit_and_create_pr method above
def commit_changes(branch_name, files, commit_message)
# Get master's latest commit and create a branch
commit = @github_client.commits(@repo, BASE_BRANCH, per_page: 1).first
create_branch(branch_name, commit)
begin
# Traverse the Git tree to find the subdirectory, delete files and create a new tree structure
updated_root_tree = update_tree(files.keys) do |current_path, tree_content|
yield current_path, tree_content
end
# Commit new tree structure
new_commit = @github_client.create_commit(@repo, commit_message, updated_root_tree.sha, commit[:sha])
@github_client.update_ref(@repo, "heads/#{branch_name}", new_commit[:sha], false)
rescue
@github_client.delete_branch(@repo, branch_name) unless branch_name == 'master'
raise
end
end
# Creates a branch
#
# @param branch_name [String] name of to-be created branch
# @param commit [#[]] the commit to point the new branch to
# @return [Sawyer::Resource] A hash representing the created branch
# @example create a branch off master
# github_helper.create_branch('feature/delete-old-migrations', github_helper.branch_commits.first)
def create_branch(branch_name, commit)
raise "Cannot create new branch. No commit given for #{BASE_BRANCH}." if commit.nil?
@github_client.create_ref(@repo, "heads/#{branch_name}", commit[:sha])
end
# Update a git tree in the specified branch
#
# @param paths_to_update [Array] array of paths to update
# @param branch [String] the branch that this method acts on
# @param procedure [Block] the procedure to be performed on the git tree content passed in via an array
# @return [Tree] A Tree object representing the updated root tree after the procedure has been performed
# @example delete files in the db/migrations folder
# github_helper.update_tree([['db', 'migrate']], 'feature/delete-old-migrations') do |path, tree_content|
# tree_content.delete_if do |content|
# ['sample_migration1.rb', 'sample_migration2.rb'].include?(content[:path])
# end
# end
def update_tree(paths_to_update, branch = BASE_BRANCH, &procedure)
raise ArgumentError, 'paths is not an array' unless paths_to_update.is_a?(Array)
# Get the root tree
root_tree_sha = @github_client.commits(@repo, branch).first[:commit][:tree][:sha]
root_tree_contents = Github::Tree.new(@github_client.tree(@repo, root_tree_sha)).contents
# Convert a list of directories into an array of arrays that represent the paths
# to be updated and each directory
# e.g. ['db/migrate', 'test/unit'] becomes [['db', 'migrate'], ['test', 'unit']]
paths_to_update = paths_to_update.map { |path| path.split('/') }
# paths_to_update would include ['.'] if the file exists in the root directory
# (ie. File.dirname('README.md') == '.')
# this represents the top level of the repository and requires special handling
if paths_to_update.include?(['.'])
paths_to_update.delete(['.'])
yield '.', root_tree_contents
end
# Update files in specified subdirectories
update_subtree(paths_to_update, root_tree_contents, [], &procedure)
end
private
# Recursive helper that searches for the target directory down the specified path,
# and then performs the procedure on that directory.
# As the recursive stack gets popped off, a new tree is created with the updated content.
def update_subtree(paths_to_update, parent_content, current_directory, &procedure)
# Group by the first entry of each array of arrays
# Example:
# paths = [['db', 'maintenance', 'maintenance'], ['unit', 'test',' maintenance'], ['db','migrate']]
# paths.group_by(&:shift)
# => {
# "db" => [["maintenance", "maintenance"], ["migrate"]],
# "unit" => [["test", " maintenance"]]
# }
subdirectory_paths = paths_to_update.group_by(&:shift)
# Find each subdirectory, get its contents
# Update the subdirectory if it's the end of the path or recursively call update_subtree
# From our example above, in the first iteration,
# subdir will be "db" and paths will be [["maintenance", "maintenance"], ["migrate"]]
subdirectory_paths.each do |subdir, paths|
directory = parent_content.find_tree(subdir)
raise "The directory #{subdir} cannot be found." if directory.nil?
target_directory = current_directory + [subdir]
target_content = Github::Tree.new(@github_client.tree(@repo, directory.sha)).contents
# If we're at the end of the path (e.g. when subdirectory_paths = { 'some_directory' => [[]] })
# This is when we want to call our procedure and create the tree
# Otherwise, we can recursively call through the tree using the paths that we just parsed out
# These paths will be grouped again and we will continue until we reach the end of each branch
if paths.include?([])
paths.delete([])
yield File.join(*target_directory), target_content
end
new_subtree = update_subtree(paths, target_content, target_directory, &procedure)
directory.sha = new_subtree.sha
end
# Create the new updated subtree
Github::Tree.new(@github_client.create_tree(@repo, parent_content.to_a))
end
def has_changes?(file_paths)
file_paths.any? do |directory, filenames|
filenames.any? do |file|
file_path = File.join(directory, file)
remote_file = @github_client.contents(@repo, path: file_path, ref: BASE_BRANCH)
local_content = File.read(file_path)
Base64.decode64(remote_file[:content]) != local_content
end
end
end
def group_files_by_dirname(paths_array)
# group each of the paths by their directory name
grouped_paths = paths_array.group_by { |path| File.dirname(path) }
# convert the hash values from the full path to basename
grouped_paths.transform_values! do |paths|
paths.map { |path| File.basename(path) }
end
grouped_paths
end
end
end
end

View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true
require 'open3'
module DevelopmentSupport
module Github
module GithubUsernameLoader
class << self
def author_github_username
# Extracts github username by attempting to ssh into git.
_, stderr, _status = Open3.capture3("ssh -o ConnectTimeout=10 git@github.com")
# Something that can't be an username at github.
username = "@@unknown@@"
stderr.each_line do |line|
next unless line.downcase.start_with?("hi")
username = line.split(" ")[1].strip
if username[-1] == "!"
username = username[0..-2]
end
break
end
[username, stderr]
end
end
end
end
end

View File

@@ -0,0 +1,16 @@
# typed: true
# frozen_string_literal: true
require_relative 'tree_content'
module DevelopmentSupport
module Github
class Tree
attr_accessor :sha, :contents
def initialize(hash_param)
@sha = hash_param[:sha]
@contents = TreeContent.new(hash_param[:tree])
end
end
end
end

View File

@@ -0,0 +1,60 @@
# typed: true
# frozen_string_literal: true
require_relative 'tree_node'
module DevelopmentSupport
module Github
class TreeContent
attr_accessor :contents
# GitHub file modes
# @see https://developer.github.com/v3/git/trees/#create-a-tree
GITHUB_FILE_MODE = {
file: '100644',
subdirectory: '040000',
submodule: '160000',
}
def initialize(contents_array)
@contents = contents_array.map { |t| Github::TreeNode.new(t) }
end
def find_tree(target_path_name)
idx = @contents.find_index do |node|
node.path == target_path_name &&
node.mode == GITHUB_FILE_MODE[:subdirectory] &&
node.type == "tree"
end
return nil if idx.nil?
@contents[idx]
end
def delete_files(filenames)
@contents.delete_if do |node|
filenames.include?(node.path) &&
node.mode == GITHUB_FILE_MODE[:file] &&
node.type == "blob"
end
end
def update_files(filenames)
@contents.each do |node|
next unless filenames.include?(node.path) && node.mode == GITHUB_FILE_MODE[:file] && node.type == "blob"
node.sha = yield(node)
end
end
def map(&block)
@contents.map(&block)
end
def size
@contents.size
end
def to_a
@contents.map(&:to_h)
end
end
end
end

View File

@@ -0,0 +1,37 @@
# typed: true
# frozen_string_literal: true
module DevelopmentSupport
module Github
class TreeNode
attr_accessor :path, :mode, :type, :size, :sha, :url
def initialize(params)
@path = params[:path]
@mode = params[:mode]
@type = params[:type]
@size = params[:size]
@sha = params[:sha]
@url = params[:url]
end
def to_h
{
path: @path,
mode: @mode,
type: @type,
sha: @sha,
}
end
def ==(other)
@path == other.path &&
@mode == other.mode &&
@type == other.type &&
@size == other.size &&
@sha == other.sha &&
@url == other.url
end
end
end
end

View File

@@ -0,0 +1,20 @@
# typed: true
# frozen_string_literal: true
require 'jwt'
module DevelopmentSupport
class JWTHelper
def self.new_jwt_token(pem)
private_key = OpenSSL::PKey::RSA.new(pem)
JWT.encode(payload, private_key, "RS256")
end
def self.payload
{
iat: Time.now.to_i,
exp: Time.now.to_i + (10 * 60),
iss: 5382,
}
end
end
end

View File

@@ -39,6 +39,16 @@ shared:
- node ./.shopify-build/balancer.js
- CI=true yarn run sewing-kit test --maxWorkers 2 --coverage $(./.shopify-build/balancer.js)
parallelism: 8
release-private-npm-package: &release-private-npm-package
label: 'Release private npm package'
container: packagecloud
timeout: 20m
dependencies:
- yarn
run:
- yarn run build
- ./.shopify-build/deploy-to-private.sh
- yarn run publish
steps:
- <<: *build-styleguide

View File

@@ -0,0 +1,6 @@
{
"_public_key": "81fc511cd064728d94083ea23a5d7f1c69be4dceb07a2a02da2fa6aa4f55d545",
"environment": {
"github_pem": "EJ[1:/dRZg7trgZRBwsaRc5F0VntoT3NDkrd70StahGNBByE=:s9peRMrjOXKAo+Y0ozdnSQFpjhSN8t+H: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]"
}
}

View File

@@ -12,15 +12,5 @@ ls -l
cd $1
ls -l
git checkout -b "$BUILDKITE_BRANCH-alpha"
git branch | grep \* | cut -d ' ' -f2
yarn upgrade @shopify/polaris@next
git status
git add --all
git status
git commit -m 'upgrade to alpha test branch'
git status
git config --global user.email "shopifybuild@shopify.com"
git config --global user.name "Shopify Build"
git push origin HEAD
git status
./create-branch

7
Gemfile Normal file
View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
source 'https://rubygems.org'
gem 'rake'
gem 'octokit'
gem 'jwt'
gem 'ejson'

29
Gemfile.lock Normal file
View File

@@ -0,0 +1,29 @@
GEM
remote: https://rubygems.org/
specs:
addressable (2.5.2)
public_suffix (>= 2.0.2, < 4.0)
ejson (1.2.1)
faraday (0.15.4)
multipart-post (>= 1.2, < 3)
jwt (2.1.0)
multipart-post (2.0.0)
octokit (4.14.0)
sawyer (~> 0.8.0, >= 0.5.3)
public_suffix (3.0.3)
rake (12.3.2)
sawyer (0.8.1)
addressable (>= 2.3.5, < 2.6)
faraday (~> 0.8, < 1.0)
PLATFORMS
ruby
DEPENDENCIES
ejson
jwt
octokit
rake
BUNDLED WITH
1.17.3

View File

@@ -1,5 +1,8 @@
name: polaris-react
up:
- ruby:
version: 2.6.2
- bundler
- node:
yarn: v1.13.0
version: v10.13.0 # to be kept in sync with .nvmrc and .circleci/config.yml

View File

@@ -1,5 +1,5 @@
{
"name": "@shopify/polaris",
"name": "@shopify/polaris-testing",
"description": "Shopifys product component library",
"version": "3.16.0",
"private": false,
@@ -11,7 +11,7 @@
"url": "https://github.com/Shopify/polaris-react/issues"
},
"publishConfig": {
"access": "public"
"access": "restricted"
},
"sideEffects": [
"**/*.css",