GitHub - Push Yeoman Generator

Octopus.Script exported 2023-10-16 by mcasperson belongs to ‘GitHub’ category.

Clones a GitHub repo, runs Yeoman in the cloned directory, and pushes the changes. Note that the Yeoman generators can only use arguments or options, as prompts can not be provided.

Parameters

When steps based on the template are included in a project’s deployment process, the parameters below can be set.

Yeoman Generator Package

PopulateGithubRepo.Yeoman.Generator.Package =

The package containing the Yeoman package. Yeoman packages are usually distributed by npm, but they can also be packaged as zip files and pushed to the built-in feed or any other feed that supports zip files.

Yeoman Generator Name

PopulateGithubRepo.Yeoman.Generator.Name =

The name of the Yeoman generator.

Yeoman Subgenerator Name

PopulateGithubRepo.Yeoman.Generator.SubGenerator =

The optional name of the Yeoman subgenerator. Leave blank to use the default generator.

Yeoman Arguments

PopulateGithubRepo.Yeoman.Generator.Arguments =

The optional arguments pass to Yeoman to define options and arguments.

GitHub Repo Name

PopulateGithubRepo.Git.Url.Repo =

The GitHub repo name i.e. myrepo in the URLhttps://github.com/owner/myrepo.

Github Owner

PopulateGithubRepo.Git.Url.Organization =

The GitHub repo owner or organization i.e. owner in the URL https://github.com/owner/myrepo.

GitHub Access Token

PopulateGithubRepo.Git.Credentials.Password =

The GitHub access token

GitHub Username

PopulateGithubRepo.Git.Credentials.Username =

This will appear in the commit logs. Leave blank to use the default username.

Script body

Steps based on this template will execute the following Python script.

# This script forks a GitHub repo. It creates a token from a GitHub App installation to avoid
# having to use a regular user account.
import subprocess
import sys

# Install our own dependencies
subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'jwt', '--disable-pip-version-check'])
subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'requests', '--disable-pip-version-check'])
subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'anyascii', '--disable-pip-version-check'])

import requests
import json
import subprocess
import sys
import os
import urllib.request
import base64
import re
import jwt
import time
import argparse
import platform
import zipfile
import lzma
import tarfile
import shutil
import urllib3
from shlex import split
from anyascii import anyascii

# Disable insecure http request warnings
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

# If this script is not being run as part of an Octopus step, setting variables is a noop
if 'set_octopusvariable' not in globals():
    def set_octopusvariable(variable, value):
        pass

# If this script is not being run as part of an Octopus step, return variables from environment variables.
# Periods are replaced with underscores, and the variable name is converted to uppercase
if "get_octopusvariable" not in globals():
    def get_octopusvariable(variable):
        return os.environ[re.sub('\\.', '_', variable.upper())]

# If this script is not being run as part of an Octopus step, print directly to std out.
if 'printverbose' not in globals():
    def printverbose(msg):
        print(msg)


def printverbose_noansi(output):
    """
    Strip ANSI color codes and print the output as verbose
    :param output: The output to print
    """
    output_no_ansi = re.sub(r'\x1b\[[0-9;]*m', '', output)
    printverbose(output_no_ansi)


def get_octopusvariable_quiet(variable):
    """
    Gets an octopus variable, or an empty string if it does not exist.
    :param variable: The variable name
    :return: The variable value, or an empty string if the variable does not exist
    """
    try:
        return get_octopusvariable(variable)
    except Exception as inst:
        return ''


def execute(args, cwd=None, env=None, print_args=None, print_output=printverbose_noansi, raise_on_non_zero=False,
            append_to_path=None):
    """
        The execute method provides the ability to execute external processes while capturing and returning the
        output to std err and std out and exit code.
    """

    my_env = os.environ.copy() if env is None else env

    if append_to_path is not None:
        my_env["PATH"] = append_to_path + os.pathsep + my_env['PATH']

    process = subprocess.Popen(args,
                               stdout=subprocess.PIPE,
                               stderr=subprocess.PIPE,
                               stdin=open(os.devnull),
                               text=True,
                               cwd=cwd,
                               env=my_env)
    stdout, stderr = process.communicate()
    retcode = process.returncode

    if not retcode == 0 and raise_on_non_zero:
        raise Exception('command returned exit code ' + retcode)

    if print_args is not None:
        print_output(' '.join(args))

    if print_output is not None:
        print_output(stdout)
        print_output(stderr)

    return stdout, stderr, retcode


def init_argparse():
    parser = argparse.ArgumentParser(
        usage='%(prog)s [OPTION]',
        description='Fork a GitHub repo'
    )
    parser.add_argument('--generator', action='store',
                        default=get_octopusvariable_quiet(
                            'PopulateGithubRepo.Yeoman.Generator.Name') or get_octopusvariable_quiet(
                            'Yeoman.Generator.Name'))
    parser.add_argument('--sub-generator', action='store',
                        default=get_octopusvariable_quiet(
                            'PopulateGithubRepo.Yeoman.Generator.SubGenerator') or get_octopusvariable_quiet(
                            'Yeoman.Generator.SubGenerator'))
    parser.add_argument('--generator-arguments', action='store',
                        default=get_octopusvariable_quiet(
                            'PopulateGithubRepo.Yeoman.Generator.Arguments') or get_octopusvariable_quiet(
                            'Yeoman.Generator.Arguments'),
                        help='The arguments to pas to yo. Pass all arguments as a single string. This string is then parsed as if it were yo arguments.')
    parser.add_argument('--repo', action='store',
                        default=get_octopusvariable_quiet(
                            'PopulateGithubRepo.Git.Url.Repo') or get_octopusvariable_quiet(
                            'Git.Url.Repo'))
    parser.add_argument('--git-organization', action='store',
                        default=get_octopusvariable_quiet(
                            'PopulateGithubRepo.Git.Url.Organization') or get_octopusvariable_quiet(
                            'Git.Url.Organization'))
    parser.add_argument('--github-app-id', action='store',
                        default=get_octopusvariable_quiet(
                            'PopulateGithubRepo.GitHub.App.Id') or get_octopusvariable_quiet('GitHub.App.Id'))
    parser.add_argument('--github-app-installation-id', action='store',
                        default=get_octopusvariable_quiet(
                            'PopulateGithubRepo.GitHub.App.InstallationId') or get_octopusvariable_quiet(
                            'GitHub.App.InstallationId'))
    parser.add_argument('--github-app-private-key', action='store',
                        default=get_octopusvariable_quiet(
                            'PopulateGithubRepo.GitHub.App.PrivateKey') or get_octopusvariable_quiet(
                            'GitHub.App.PrivateKey'))
    parser.add_argument('--git-password', action='store',
                        default=get_octopusvariable_quiet(
                            'PopulateGithubRepo.Git.Credentials.Password') or get_octopusvariable_quiet(
                            'Git.Credentials.Password'),
                        help='The git password. This takes precedence over the --github-app-id,  --github-app-installation-id, and --github-app-private-key')
    parser.add_argument('--git-username', action='store',
                        default=get_octopusvariable_quiet(
                            'PopulateGithubRepo.Git.Credentials.Username') or get_octopusvariable_quiet(
                            'Git.Credentials.Username'),
                        help='The git username. This will be used for both the git authentication and the username associated with any commits.')

    return parser.parse_known_args()


def generate_github_token(github_app_id, github_app_private_key, github_app_installation_id):
    # Generate the tokens used by git and the GitHub API
    app_id = github_app_id
    signing_key = jwt.jwk_from_pem(github_app_private_key.encode('utf-8'))

    payload = {
        # Issued at time
        'iat': int(time.time()),
        # JWT expiration time (10 minutes maximum)
        'exp': int(time.time()) + 600,
        # GitHub App's identifier
        'iss': app_id
    }

    # Create JWT
    jwt_instance = jwt.JWT()
    encoded_jwt = jwt_instance.encode(payload, signing_key, alg='RS256')

    # Create access token
    url = 'https://api.github.com/app/installations/' + github_app_installation_id + '/access_tokens'
    headers = {
        'Authorization': 'Bearer ' + encoded_jwt,
        'Accept': 'application/vnd.github+json',
        'X-GitHub-Api-Version': '2022-11-28'
    }
    request = urllib.request.Request(url, headers=headers, method='POST')
    response = urllib.request.urlopen(request)
    response_json = json.loads(response.read().decode())
    return response_json['token']


def generate_auth_header(token):
    auth = base64.b64encode(('x-access-token:' + token).encode('ascii'))
    return 'Basic ' + auth.decode('ascii')


def verify_new_repo(token, cac_org, new_repo):
    # Attempt to view the new repo
    try:
        url = 'https://api.github.com/repos/' + cac_org + '/' + new_repo
        headers = {
            'Accept': 'application/vnd.github+json',
            'Authorization': 'Bearer ' + token,
            'X-GitHub-Api-Version': '2022-11-28'
        }
        request = urllib.request.Request(url, headers=headers)
        urllib.request.urlopen(request)
        return True
    except Exception as inst:
        return False


def is_windows():
    return platform.system() == 'Windows'


def download_file(url, filename, verify_ssl=True):
    r = requests.get(url, verify=verify_ssl)
    with open(filename, 'wb') as file:
        file.write(r.content)


def ensure_git_exists():
    if is_windows():
        print("Checking git is installed")
        try:
            stdout, _, exit_code = execute(['git', 'version'])
            printverbose(stdout)
            if not exit_code == 0:
                raise "git not found"
        except:
            print("Downloading git")
            download_file('https://www.7-zip.org/a/7zr.exe', '7zr.exe')
            download_file(
                'https://github.com/git-for-windows/git/releases/download/v2.42.0.windows.2/PortableGit-2.42.0.2-64-bit.7z.exe',
                'PortableGit.7z.exe')
            print("Installing git")
            print("Consider installing git on the worker or using a standard worker-tools image")
            execute(['7zr.exe', 'x', 'PortableGit.7z.exe', '-o' + os.path.join(os.getcwd(), 'git'), '-y'])
            return os.path.join(os.getcwd(), 'git', 'bin', 'git')

    return 'git'


def install_npm_linux():
    print("Downloading node")
    download_file(
        'https://nodejs.org/dist/v18.18.2/node-v18.18.2-linux-x64.tar.xz',
        'node.tar.xz')
    print("Installing node on Linux")
    with lzma.open("node.tar.xz", "r") as lzma_ref:
        with open("node.tar", "wb") as fdst:
            shutil.copyfileobj(lzma_ref, fdst)
    with tarfile.open("node.tar", "r") as tar_ref:
        tar_ref.extractall(os.getcwd())

    try:
        _, _, exit_code = execute([os.getcwd() + '/node-v18.18.2-linux-x64/bin/npm', '--version'],
                                  append_to_path=os.getcwd() + '/node-v18.18.2-linux-x64/bin')
        if not exit_code == 0:
            raise Exception("Failed to run npm")
    except Exception as ex:
        print('Failed to install npm ' + str(ex))
        sys.exit(1)
    return os.getcwd() + '/node-v18.18.2-linux-x64/bin/npm', os.getcwd() + '/node-v18.18.2-linux-x64/bin'


def install_npm_windows():
    print("Downloading node")
    download_file('https://nodejs.org/dist/v18.18.2/node-v18.18.2-win-x64.zip', 'node.zip', False)
    print("Installing node on Windows")
    with zipfile.ZipFile("node.zip", "r") as zip_ref:
        zip_ref.extractall(os.getcwd())
    try:
        _, _, exit_code = execute([os.path.join(os.getcwd(), 'node-v18.18.2-win-x64', 'npm.cmd'), '--version'],
                                  append_to_path=os.path.join(os.getcwd(), 'node-v18.18.2-win-x64'))
        if not exit_code == 0:
            raise Exception("Failed to run npm")
    except Exception as ex:
        print('Failed to install npm ' + str(ex))
        sys.exit(1)

    return (os.path.join(os.getcwd(), 'node-v18.18.2-win-x64', 'npm.cmd'),
            os.path.join(os.getcwd(), 'node-v18.18.2-win-x64'))


def ensure_node_exists():
    try:
        print("Checking node is installed")
        _, _, exit_code = execute(['npm', '--version'])
        if not exit_code == 0:
            raise Exception("npm not found")
    except:
        if is_windows():
            return install_npm_windows()
        else:
            return install_npm_linux()

    return 'npm', None


def ensure_yo_exists(npm_executable, npm_path):
    try:
        print("Checking Yeoman is installed")
        _, _, exit_code = execute(['yo', '--version'])
        if not exit_code == 0:
            raise Exception("yo not found")
    except:
        print('Installing Yeoman')

        _, _, retcode = execute([npm_executable, 'install', '-g', 'yo'], append_to_path=npm_path)

        if not retcode == 0:
            print("Failed to set install Yeoman. Check the verbose logs for details.")
            sys.exit(1)

        npm_bin, _, retcode = execute([npm_executable, 'config', 'get', 'prefix'], append_to_path=npm_path)

        if not retcode == 0:
            print("Failed to set get the npm prefix directory. Check the verbose logs for details.")
            sys.exit(1)

        try:
            if is_windows():
                _, _, exit_code = execute([os.path.join(npm_bin.strip(), 'yo.cmd'), '--version'],
                                          append_to_path=npm_path)
            else:
                _, _, exit_code = execute([os.path.join(npm_bin.strip(), 'bin', 'yo'), '--version'],
                                          append_to_path=npm_path)

            if not exit_code == 0:
                raise Exception("Failed to run yo")
        except Exception as ex:
            print('Failed to install yo ' + str(ex))
            sys.exit(1)

        # Windows and Linux save NPM binaries in different directories
        if is_windows():
            return os.path.join(npm_bin.strip(), 'yo.cmd')

        return os.path.join(npm_bin.strip(), 'bin', 'yo')

    return 'yo'


git_executable = ensure_git_exists()
npm_executable, npm_path = ensure_node_exists()
yo_executable = ensure_yo_exists(npm_executable, npm_path)
parser, _ = init_argparse()

if not parser.git_password.strip() and not (
        parser.github_app_id.strip() and parser.github_app_private_key.strip() and parser.github_app_installation_id.strip()):
    print("You must supply the GitHub token, or the GitHub App ID and private key and installation ID")
    sys.exit(1)

if not parser.git_organization.strip():
    print("You must define the organization")
    sys.exit(1)

if not parser.repo.strip():
    print("You must define the repo name")
    sys.exit(1)

if not parser.generator.strip():
    print("You must define the Yeoman generator")
    sys.exit(1)

# Create a dir for the git clone
if os.path.exists('downstream'):
    shutil.rmtree('downstream')

os.mkdir('downstream')

# Create a dir for yeoman to use
if os.path.exists('downstream-yeoman'):
    shutil.rmtree('downstream-yeoman')

os.mkdir('downstream-yeoman')
# Yeoman will use a less privileged user to write to this directory, so grant full access
if not is_windows():
    os.chmod('downstream-yeoman', 0o777)

downstream_dir = os.path.join(os.getcwd(), 'downstream')
downstream_yeoman_dir = os.path.join(os.getcwd(), 'downstream-yeoman')

# The access token is generated from a github app or supplied directly as an access token
token = generate_github_token(parser.github_app_id, parser.github_app_private_key,
                              parser.github_app_installation_id) if len(
    parser.git_password.strip()) == 0 else parser.git_password.strip()

if not verify_new_repo(token, parser.git_organization, parser.repo):
    print('Repo at https://github.com/' + parser.git_organization + '/' + parser.repo + ' could not be accessed')
    sys.exit(1)

# We need to disable the credentials helper prompt, which will pop open a GUI prompt that we can never close
if is_windows():
    _, _, retcode = execute([git_executable, 'config', '--system', 'credential.helper', 'manager'])

    if not retcode == 0:
        print("Failed to set the credential.helper setting. Check the verbose logs for details.")
        sys.exit(1)

    _, _, retcode = execute([git_executable, 'config', '--system', 'credential.modalprompt', 'false'])

    if not retcode == 0:
        print("Failed to srt the credential.modalprompt setting. Check the verbose logs for details.")
        sys.exit(1)

    # We need to disable the credentials helper prompt, which will pop open a GUI prompt that we can never close
    _, _, retcode = execute(
        [git_executable, 'config', '--system', 'credential.microsoft.visualstudio.com.interactive', 'never'])

    if not retcode == 0:
        print(
            "Failed to set the credential.microsoft.visualstudio.com.interactive setting. Check the verbose logs for details.")
        sys.exit(1)

_, _, retcode = execute([git_executable, 'config', '--global', 'user.email', 'octopus@octopus.com'])

if not retcode == 0:
    print("Failed to set the user.email setting. Check the verbose logs for details.")
    sys.exit(1)

_, _, retcode = execute([git_executable, 'config', '--global', 'core.autocrlf', 'input'])

if not retcode == 0:
    print("Failed to set the core.autocrlf setting. Check the verbose logs for details.")
    sys.exit(1)

username = parser.git_username if len(parser.git_username) != 0 else 'Octopus'
_, _, retcode = execute([git_executable, 'config', '--global', 'user.name', username])

if not retcode == 0:
    print("Failed to set the git username. Check the verbose logs for details.")
    sys.exit(1)

_, _, retcode = execute([git_executable, 'config', '--global', 'credential.helper', 'cache'])

if not retcode == 0:
    print("Failed to set the git credential helper. Check the verbose logs for details.")
    sys.exit(1)

print('Cloning repo')

_, _, retcode = execute(
    [git_executable, 'clone',
     'https://' + username + ':' + token + '@github.com/' + parser.git_organization + '/' + parser.repo + '.git',
     'downstream'])

if not retcode == 0:
    print("Failed to clone the git repo. Check the verbose logs for details.")
    sys.exit(1)

print('Configuring Yeoman Generator')

_, _, retcode = execute([npm_executable, 'install'], cwd=os.path.join(os.getcwd(), 'YeomanGenerator'), append_to_path=npm_path)

if not retcode == 0:
    print("Failed to install the generator dependencies. Check the verbose logs for details.")
    sys.exit(1)

_, _, retcode = execute([npm_executable, 'link'], cwd=os.path.join(os.getcwd(), 'YeomanGenerator'), append_to_path=npm_path)

if not retcode == 0:
    print("Failed to link the npm module. Check the verbose logs for details.")
    sys.exit(1)

print('Running Yeoman Generator')

# Treat the string of yo arguments as a raw input and parse it again. The resulting list of unknown arguments
# is then passed to yo. We have to convert the incoming values from utf to ascii when parsing a second time.
yo_args = split(anyascii(parser.generator_arguments))

generator_name = parser.generator + ':' + parser.sub_generator if len(parser.sub_generator) != 0 else parser.generator

yo_arguments = [yo_executable, generator_name, '--force', '--skip-install']

# Yeoman has issues running as root, which it will often do in a container.
# So we run Yeoman in its own directory, and then copy the changes to the git directory.
_, _, retcode = execute(yo_arguments + yo_args, cwd=downstream_yeoman_dir, append_to_path=npm_path)

if not retcode == 0:
    print("Failed to run Yeoman. Check the verbose logs for details.")
    sys.exit(1)

shutil.copytree(downstream_yeoman_dir, downstream_dir, dirs_exist_ok=True)

print('Adding changes to git')

_, _, retcode = execute([git_executable, 'add', '.'], cwd=downstream_dir)

if not retcode == 0:
    print("Failed to add the git changes. Check the verbose logs for details.")
    sys.exit(1)

# Check for pending changes
_, _, retcode = execute([git_executable, 'diff-index', '--quiet', 'HEAD'], cwd=downstream_dir)

if not retcode == 0:
    print('Committing changes to git')
    _, _, retcode = execute([git_executable, 'commit', '-m',
                             'Added files from Yeoman generator ' + parser.generator + ':' + parser.sub_generator],
                            cwd=downstream_dir)

    if not retcode == 0:
        print("Failed to set commit the git changes. Check the verbose logs for details.")
        sys.exit(1)

    print('Pushing changes to git')

    _, _, retcode = execute([git_executable, 'push', 'origin', 'main'], cwd=downstream_dir)

    if not retcode == 0:
        print("Failed to push the git changes. Check the verbose logs for details.")
        sys.exit(1)

Provided under the Apache License version 2.0.

Report an issue

To use this template in Octopus Deploy, copy the JSON below and paste it into the Library → Step templates → Import dialog.

{
  "Id": "14ffc4b7-1cab-4f81-a835-7da41fa47123",
  "Name": "GitHub - Push Yeoman Generator",
  "Description": "Clones a GitHub repo, runs Yeoman in the cloned directory, and pushes the changes. Note that the Yeoman generators can only use arguments or options, as prompts can not be provided.",
  "Version": 1,
  "ExportedAt": "2023-10-16T23:49:40.031Z",
  "ActionType": "Octopus.Script",
  "Author": "mcasperson",
  "Packages": [
    {
      "Id": "6bd0fbd2-442e-4ae9-9192-b93a041cfdd1",
      "Name": "YeomanGenerator",
      "AcquisitionLocation": "Server",
      "FeedId": null,
      "Properties": {
        "Extract": "True",
        "SelectionMode": "deferred",
        "PackageParameterName": "PopulateGithubRepo.Yeoman.Generator.Package",
        "Purpose": ""
      }
    }
  ],
  "Parameters": [
    {
      "Id": "334f5b32-01a8-4687-af33-54906e53cdee",
      "Name": "PopulateGithubRepo.Yeoman.Generator.Package",
      "Label": "Yeoman Generator Package",
      "HelpText": "The package containing the Yeoman package. Yeoman packages are usually distributed by npm, but they can also be packaged as zip files and pushed to the built-in feed or any other feed that supports zip files.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "Package"
      }
    },
    {
      "Id": "d1c9bf99-5f1f-417b-a482-a72878065871",
      "Name": "PopulateGithubRepo.Yeoman.Generator.Name",
      "Label": "Yeoman Generator Name",
      "HelpText": "The name of the Yeoman generator.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "2c1dd445-c1d4-4e44-91a0-b7f02682711b",
      "Name": "PopulateGithubRepo.Yeoman.Generator.SubGenerator",
      "Label": "Yeoman Subgenerator Name",
      "HelpText": "The optional name of the Yeoman subgenerator. Leave blank to use the default generator.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "bac9200a-2fce-4390-9b07-40e2a14c2e44",
      "Name": "PopulateGithubRepo.Yeoman.Generator.Arguments",
      "Label": "Yeoman Arguments",
      "HelpText": "The optional arguments pass to Yeoman to define options and arguments.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "f5c1092a-70d8-40ba-84e9-feedf5911946",
      "Name": "PopulateGithubRepo.Git.Url.Repo",
      "Label": "GitHub Repo Name",
      "HelpText": "The GitHub repo name i.e. `myrepo` in the URL`https://github.com/owner/myrepo`.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "ece2ee80-9572-4ca1-ab0a-65ffaec2edb7",
      "Name": "PopulateGithubRepo.Git.Url.Organization",
      "Label": "Github Owner",
      "HelpText": "The GitHub repo owner or organization i.e. `owner` in the URL `https://github.com/owner/myrepo`.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "4795ec27-f38b-4f2f-8417-97c3cd74d2db",
      "Name": "PopulateGithubRepo.Git.Credentials.Password",
      "Label": "GitHub Access Token",
      "HelpText": "The GitHub access token",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "300596fd-59b1-420e-b58f-305b7c83b54d",
      "Name": "PopulateGithubRepo.Git.Credentials.Username",
      "Label": "GitHub Username",
      "HelpText": "This will appear in the commit logs. Leave blank to use the default username.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    }
  ],
  "Properties": {
    "Octopus.Action.RunOnServer": "true",
    "Octopus.Action.Script.ScriptSource": "Inline",
    "Octopus.Action.Script.Syntax": "Python",
    "Octopus.Action.Script.ScriptBody": "# This script forks a GitHub repo. It creates a token from a GitHub App installation to avoid\n# having to use a regular user account.\nimport subprocess\nimport sys\n\n# Install our own dependencies\nsubprocess.check_call([sys.executable, '-m', 'pip', 'install', 'jwt', '--disable-pip-version-check'])\nsubprocess.check_call([sys.executable, '-m', 'pip', 'install', 'requests', '--disable-pip-version-check'])\nsubprocess.check_call([sys.executable, '-m', 'pip', 'install', 'anyascii', '--disable-pip-version-check'])\n\nimport requests\nimport json\nimport subprocess\nimport sys\nimport os\nimport urllib.request\nimport base64\nimport re\nimport jwt\nimport time\nimport argparse\nimport platform\nimport zipfile\nimport lzma\nimport tarfile\nimport shutil\nimport urllib3\nfrom shlex import split\nfrom anyascii import anyascii\n\n# Disable insecure http request warnings\nurllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)\n\n# If this script is not being run as part of an Octopus step, setting variables is a noop\nif 'set_octopusvariable' not in globals():\n    def set_octopusvariable(variable, value):\n        pass\n\n# If this script is not being run as part of an Octopus step, return variables from environment variables.\n# Periods are replaced with underscores, and the variable name is converted to uppercase\nif \"get_octopusvariable\" not in globals():\n    def get_octopusvariable(variable):\n        return os.environ[re.sub('\\\\.', '_', variable.upper())]\n\n# If this script is not being run as part of an Octopus step, print directly to std out.\nif 'printverbose' not in globals():\n    def printverbose(msg):\n        print(msg)\n\n\ndef printverbose_noansi(output):\n    \"\"\"\n    Strip ANSI color codes and print the output as verbose\n    :param output: The output to print\n    \"\"\"\n    output_no_ansi = re.sub(r'\\x1b\\[[0-9;]*m', '', output)\n    printverbose(output_no_ansi)\n\n\ndef get_octopusvariable_quiet(variable):\n    \"\"\"\n    Gets an octopus variable, or an empty string if it does not exist.\n    :param variable: The variable name\n    :return: The variable value, or an empty string if the variable does not exist\n    \"\"\"\n    try:\n        return get_octopusvariable(variable)\n    except Exception as inst:\n        return ''\n\n\ndef execute(args, cwd=None, env=None, print_args=None, print_output=printverbose_noansi, raise_on_non_zero=False,\n            append_to_path=None):\n    \"\"\"\n        The execute method provides the ability to execute external processes while capturing and returning the\n        output to std err and std out and exit code.\n    \"\"\"\n\n    my_env = os.environ.copy() if env is None else env\n\n    if append_to_path is not None:\n        my_env[\"PATH\"] = append_to_path + os.pathsep + my_env['PATH']\n\n    process = subprocess.Popen(args,\n                               stdout=subprocess.PIPE,\n                               stderr=subprocess.PIPE,\n                               stdin=open(os.devnull),\n                               text=True,\n                               cwd=cwd,\n                               env=my_env)\n    stdout, stderr = process.communicate()\n    retcode = process.returncode\n\n    if not retcode == 0 and raise_on_non_zero:\n        raise Exception('command returned exit code ' + retcode)\n\n    if print_args is not None:\n        print_output(' '.join(args))\n\n    if print_output is not None:\n        print_output(stdout)\n        print_output(stderr)\n\n    return stdout, stderr, retcode\n\n\ndef init_argparse():\n    parser = argparse.ArgumentParser(\n        usage='%(prog)s [OPTION]',\n        description='Fork a GitHub repo'\n    )\n    parser.add_argument('--generator', action='store',\n                        default=get_octopusvariable_quiet(\n                            'PopulateGithubRepo.Yeoman.Generator.Name') or get_octopusvariable_quiet(\n                            'Yeoman.Generator.Name'))\n    parser.add_argument('--sub-generator', action='store',\n                        default=get_octopusvariable_quiet(\n                            'PopulateGithubRepo.Yeoman.Generator.SubGenerator') or get_octopusvariable_quiet(\n                            'Yeoman.Generator.SubGenerator'))\n    parser.add_argument('--generator-arguments', action='store',\n                        default=get_octopusvariable_quiet(\n                            'PopulateGithubRepo.Yeoman.Generator.Arguments') or get_octopusvariable_quiet(\n                            'Yeoman.Generator.Arguments'),\n                        help='The arguments to pas to yo. Pass all arguments as a single string. This string is then parsed as if it were yo arguments.')\n    parser.add_argument('--repo', action='store',\n                        default=get_octopusvariable_quiet(\n                            'PopulateGithubRepo.Git.Url.Repo') or get_octopusvariable_quiet(\n                            'Git.Url.Repo'))\n    parser.add_argument('--git-organization', action='store',\n                        default=get_octopusvariable_quiet(\n                            'PopulateGithubRepo.Git.Url.Organization') or get_octopusvariable_quiet(\n                            'Git.Url.Organization'))\n    parser.add_argument('--github-app-id', action='store',\n                        default=get_octopusvariable_quiet(\n                            'PopulateGithubRepo.GitHub.App.Id') or get_octopusvariable_quiet('GitHub.App.Id'))\n    parser.add_argument('--github-app-installation-id', action='store',\n                        default=get_octopusvariable_quiet(\n                            'PopulateGithubRepo.GitHub.App.InstallationId') or get_octopusvariable_quiet(\n                            'GitHub.App.InstallationId'))\n    parser.add_argument('--github-app-private-key', action='store',\n                        default=get_octopusvariable_quiet(\n                            'PopulateGithubRepo.GitHub.App.PrivateKey') or get_octopusvariable_quiet(\n                            'GitHub.App.PrivateKey'))\n    parser.add_argument('--git-password', action='store',\n                        default=get_octopusvariable_quiet(\n                            'PopulateGithubRepo.Git.Credentials.Password') or get_octopusvariable_quiet(\n                            'Git.Credentials.Password'),\n                        help='The git password. This takes precedence over the --github-app-id,  --github-app-installation-id, and --github-app-private-key')\n    parser.add_argument('--git-username', action='store',\n                        default=get_octopusvariable_quiet(\n                            'PopulateGithubRepo.Git.Credentials.Username') or get_octopusvariable_quiet(\n                            'Git.Credentials.Username'),\n                        help='The git username. This will be used for both the git authentication and the username associated with any commits.')\n\n    return parser.parse_known_args()\n\n\ndef generate_github_token(github_app_id, github_app_private_key, github_app_installation_id):\n    # Generate the tokens used by git and the GitHub API\n    app_id = github_app_id\n    signing_key = jwt.jwk_from_pem(github_app_private_key.encode('utf-8'))\n\n    payload = {\n        # Issued at time\n        'iat': int(time.time()),\n        # JWT expiration time (10 minutes maximum)\n        'exp': int(time.time()) + 600,\n        # GitHub App's identifier\n        'iss': app_id\n    }\n\n    # Create JWT\n    jwt_instance = jwt.JWT()\n    encoded_jwt = jwt_instance.encode(payload, signing_key, alg='RS256')\n\n    # Create access token\n    url = 'https://api.github.com/app/installations/' + github_app_installation_id + '/access_tokens'\n    headers = {\n        'Authorization': 'Bearer ' + encoded_jwt,\n        'Accept': 'application/vnd.github+json',\n        'X-GitHub-Api-Version': '2022-11-28'\n    }\n    request = urllib.request.Request(url, headers=headers, method='POST')\n    response = urllib.request.urlopen(request)\n    response_json = json.loads(response.read().decode())\n    return response_json['token']\n\n\ndef generate_auth_header(token):\n    auth = base64.b64encode(('x-access-token:' + token).encode('ascii'))\n    return 'Basic ' + auth.decode('ascii')\n\n\ndef verify_new_repo(token, cac_org, new_repo):\n    # Attempt to view the new repo\n    try:\n        url = 'https://api.github.com/repos/' + cac_org + '/' + new_repo\n        headers = {\n            'Accept': 'application/vnd.github+json',\n            'Authorization': 'Bearer ' + token,\n            'X-GitHub-Api-Version': '2022-11-28'\n        }\n        request = urllib.request.Request(url, headers=headers)\n        urllib.request.urlopen(request)\n        return True\n    except Exception as inst:\n        return False\n\n\ndef is_windows():\n    return platform.system() == 'Windows'\n\n\ndef download_file(url, filename, verify_ssl=True):\n    r = requests.get(url, verify=verify_ssl)\n    with open(filename, 'wb') as file:\n        file.write(r.content)\n\n\ndef ensure_git_exists():\n    if is_windows():\n        print(\"Checking git is installed\")\n        try:\n            stdout, _, exit_code = execute(['git', 'version'])\n            printverbose(stdout)\n            if not exit_code == 0:\n                raise \"git not found\"\n        except:\n            print(\"Downloading git\")\n            download_file('https://www.7-zip.org/a/7zr.exe', '7zr.exe')\n            download_file(\n                'https://github.com/git-for-windows/git/releases/download/v2.42.0.windows.2/PortableGit-2.42.0.2-64-bit.7z.exe',\n                'PortableGit.7z.exe')\n            print(\"Installing git\")\n            print(\"Consider installing git on the worker or using a standard worker-tools image\")\n            execute(['7zr.exe', 'x', 'PortableGit.7z.exe', '-o' + os.path.join(os.getcwd(), 'git'), '-y'])\n            return os.path.join(os.getcwd(), 'git', 'bin', 'git')\n\n    return 'git'\n\n\ndef install_npm_linux():\n    print(\"Downloading node\")\n    download_file(\n        'https://nodejs.org/dist/v18.18.2/node-v18.18.2-linux-x64.tar.xz',\n        'node.tar.xz')\n    print(\"Installing node on Linux\")\n    with lzma.open(\"node.tar.xz\", \"r\") as lzma_ref:\n        with open(\"node.tar\", \"wb\") as fdst:\n            shutil.copyfileobj(lzma_ref, fdst)\n    with tarfile.open(\"node.tar\", \"r\") as tar_ref:\n        tar_ref.extractall(os.getcwd())\n\n    try:\n        _, _, exit_code = execute([os.getcwd() + '/node-v18.18.2-linux-x64/bin/npm', '--version'],\n                                  append_to_path=os.getcwd() + '/node-v18.18.2-linux-x64/bin')\n        if not exit_code == 0:\n            raise Exception(\"Failed to run npm\")\n    except Exception as ex:\n        print('Failed to install npm ' + str(ex))\n        sys.exit(1)\n    return os.getcwd() + '/node-v18.18.2-linux-x64/bin/npm', os.getcwd() + '/node-v18.18.2-linux-x64/bin'\n\n\ndef install_npm_windows():\n    print(\"Downloading node\")\n    download_file('https://nodejs.org/dist/v18.18.2/node-v18.18.2-win-x64.zip', 'node.zip', False)\n    print(\"Installing node on Windows\")\n    with zipfile.ZipFile(\"node.zip\", \"r\") as zip_ref:\n        zip_ref.extractall(os.getcwd())\n    try:\n        _, _, exit_code = execute([os.path.join(os.getcwd(), 'node-v18.18.2-win-x64', 'npm.cmd'), '--version'],\n                                  append_to_path=os.path.join(os.getcwd(), 'node-v18.18.2-win-x64'))\n        if not exit_code == 0:\n            raise Exception(\"Failed to run npm\")\n    except Exception as ex:\n        print('Failed to install npm ' + str(ex))\n        sys.exit(1)\n\n    return (os.path.join(os.getcwd(), 'node-v18.18.2-win-x64', 'npm.cmd'),\n            os.path.join(os.getcwd(), 'node-v18.18.2-win-x64'))\n\n\ndef ensure_node_exists():\n    try:\n        print(\"Checking node is installed\")\n        _, _, exit_code = execute(['npm', '--version'])\n        if not exit_code == 0:\n            raise Exception(\"npm not found\")\n    except:\n        if is_windows():\n            return install_npm_windows()\n        else:\n            return install_npm_linux()\n\n    return 'npm', None\n\n\ndef ensure_yo_exists(npm_executable, npm_path):\n    try:\n        print(\"Checking Yeoman is installed\")\n        _, _, exit_code = execute(['yo', '--version'])\n        if not exit_code == 0:\n            raise Exception(\"yo not found\")\n    except:\n        print('Installing Yeoman')\n\n        _, _, retcode = execute([npm_executable, 'install', '-g', 'yo'], append_to_path=npm_path)\n\n        if not retcode == 0:\n            print(\"Failed to set install Yeoman. Check the verbose logs for details.\")\n            sys.exit(1)\n\n        npm_bin, _, retcode = execute([npm_executable, 'config', 'get', 'prefix'], append_to_path=npm_path)\n\n        if not retcode == 0:\n            print(\"Failed to set get the npm prefix directory. Check the verbose logs for details.\")\n            sys.exit(1)\n\n        try:\n            if is_windows():\n                _, _, exit_code = execute([os.path.join(npm_bin.strip(), 'yo.cmd'), '--version'],\n                                          append_to_path=npm_path)\n            else:\n                _, _, exit_code = execute([os.path.join(npm_bin.strip(), 'bin', 'yo'), '--version'],\n                                          append_to_path=npm_path)\n\n            if not exit_code == 0:\n                raise Exception(\"Failed to run yo\")\n        except Exception as ex:\n            print('Failed to install yo ' + str(ex))\n            sys.exit(1)\n\n        # Windows and Linux save NPM binaries in different directories\n        if is_windows():\n            return os.path.join(npm_bin.strip(), 'yo.cmd')\n\n        return os.path.join(npm_bin.strip(), 'bin', 'yo')\n\n    return 'yo'\n\n\ngit_executable = ensure_git_exists()\nnpm_executable, npm_path = ensure_node_exists()\nyo_executable = ensure_yo_exists(npm_executable, npm_path)\nparser, _ = init_argparse()\n\nif not parser.git_password.strip() and not (\n        parser.github_app_id.strip() and parser.github_app_private_key.strip() and parser.github_app_installation_id.strip()):\n    print(\"You must supply the GitHub token, or the GitHub App ID and private key and installation ID\")\n    sys.exit(1)\n\nif not parser.git_organization.strip():\n    print(\"You must define the organization\")\n    sys.exit(1)\n\nif not parser.repo.strip():\n    print(\"You must define the repo name\")\n    sys.exit(1)\n\nif not parser.generator.strip():\n    print(\"You must define the Yeoman generator\")\n    sys.exit(1)\n\n# Create a dir for the git clone\nif os.path.exists('downstream'):\n    shutil.rmtree('downstream')\n\nos.mkdir('downstream')\n\n# Create a dir for yeoman to use\nif os.path.exists('downstream-yeoman'):\n    shutil.rmtree('downstream-yeoman')\n\nos.mkdir('downstream-yeoman')\n# Yeoman will use a less privileged user to write to this directory, so grant full access\nif not is_windows():\n    os.chmod('downstream-yeoman', 0o777)\n\ndownstream_dir = os.path.join(os.getcwd(), 'downstream')\ndownstream_yeoman_dir = os.path.join(os.getcwd(), 'downstream-yeoman')\n\n# The access token is generated from a github app or supplied directly as an access token\ntoken = generate_github_token(parser.github_app_id, parser.github_app_private_key,\n                              parser.github_app_installation_id) if len(\n    parser.git_password.strip()) == 0 else parser.git_password.strip()\n\nif not verify_new_repo(token, parser.git_organization, parser.repo):\n    print('Repo at https://github.com/' + parser.git_organization + '/' + parser.repo + ' could not be accessed')\n    sys.exit(1)\n\n# We need to disable the credentials helper prompt, which will pop open a GUI prompt that we can never close\nif is_windows():\n    _, _, retcode = execute([git_executable, 'config', '--system', 'credential.helper', 'manager'])\n\n    if not retcode == 0:\n        print(\"Failed to set the credential.helper setting. Check the verbose logs for details.\")\n        sys.exit(1)\n\n    _, _, retcode = execute([git_executable, 'config', '--system', 'credential.modalprompt', 'false'])\n\n    if not retcode == 0:\n        print(\"Failed to srt the credential.modalprompt setting. Check the verbose logs for details.\")\n        sys.exit(1)\n\n    # We need to disable the credentials helper prompt, which will pop open a GUI prompt that we can never close\n    _, _, retcode = execute(\n        [git_executable, 'config', '--system', 'credential.microsoft.visualstudio.com.interactive', 'never'])\n\n    if not retcode == 0:\n        print(\n            \"Failed to set the credential.microsoft.visualstudio.com.interactive setting. Check the verbose logs for details.\")\n        sys.exit(1)\n\n_, _, retcode = execute([git_executable, 'config', '--global', 'user.email', 'octopus@octopus.com'])\n\nif not retcode == 0:\n    print(\"Failed to set the user.email setting. Check the verbose logs for details.\")\n    sys.exit(1)\n\n_, _, retcode = execute([git_executable, 'config', '--global', 'core.autocrlf', 'input'])\n\nif not retcode == 0:\n    print(\"Failed to set the core.autocrlf setting. Check the verbose logs for details.\")\n    sys.exit(1)\n\nusername = parser.git_username if len(parser.git_username) != 0 else 'Octopus'\n_, _, retcode = execute([git_executable, 'config', '--global', 'user.name', username])\n\nif not retcode == 0:\n    print(\"Failed to set the git username. Check the verbose logs for details.\")\n    sys.exit(1)\n\n_, _, retcode = execute([git_executable, 'config', '--global', 'credential.helper', 'cache'])\n\nif not retcode == 0:\n    print(\"Failed to set the git credential helper. Check the verbose logs for details.\")\n    sys.exit(1)\n\nprint('Cloning repo')\n\n_, _, retcode = execute(\n    [git_executable, 'clone',\n     'https://' + username + ':' + token + '@github.com/' + parser.git_organization + '/' + parser.repo + '.git',\n     'downstream'])\n\nif not retcode == 0:\n    print(\"Failed to clone the git repo. Check the verbose logs for details.\")\n    sys.exit(1)\n\nprint('Configuring Yeoman Generator')\n\n_, _, retcode = execute([npm_executable, 'install'], cwd=os.path.join(os.getcwd(), 'YeomanGenerator'), append_to_path=npm_path)\n\nif not retcode == 0:\n    print(\"Failed to install the generator dependencies. Check the verbose logs for details.\")\n    sys.exit(1)\n\n_, _, retcode = execute([npm_executable, 'link'], cwd=os.path.join(os.getcwd(), 'YeomanGenerator'), append_to_path=npm_path)\n\nif not retcode == 0:\n    print(\"Failed to link the npm module. Check the verbose logs for details.\")\n    sys.exit(1)\n\nprint('Running Yeoman Generator')\n\n# Treat the string of yo arguments as a raw input and parse it again. The resulting list of unknown arguments\n# is then passed to yo. We have to convert the incoming values from utf to ascii when parsing a second time.\nyo_args = split(anyascii(parser.generator_arguments))\n\ngenerator_name = parser.generator + ':' + parser.sub_generator if len(parser.sub_generator) != 0 else parser.generator\n\nyo_arguments = [yo_executable, generator_name, '--force', '--skip-install']\n\n# Yeoman has issues running as root, which it will often do in a container.\n# So we run Yeoman in its own directory, and then copy the changes to the git directory.\n_, _, retcode = execute(yo_arguments + yo_args, cwd=downstream_yeoman_dir, append_to_path=npm_path)\n\nif not retcode == 0:\n    print(\"Failed to run Yeoman. Check the verbose logs for details.\")\n    sys.exit(1)\n\nshutil.copytree(downstream_yeoman_dir, downstream_dir, dirs_exist_ok=True)\n\nprint('Adding changes to git')\n\n_, _, retcode = execute([git_executable, 'add', '.'], cwd=downstream_dir)\n\nif not retcode == 0:\n    print(\"Failed to add the git changes. Check the verbose logs for details.\")\n    sys.exit(1)\n\n# Check for pending changes\n_, _, retcode = execute([git_executable, 'diff-index', '--quiet', 'HEAD'], cwd=downstream_dir)\n\nif not retcode == 0:\n    print('Committing changes to git')\n    _, _, retcode = execute([git_executable, 'commit', '-m',\n                             'Added files from Yeoman generator ' + parser.generator + ':' + parser.sub_generator],\n                            cwd=downstream_dir)\n\n    if not retcode == 0:\n        print(\"Failed to set commit the git changes. Check the verbose logs for details.\")\n        sys.exit(1)\n\n    print('Pushing changes to git')\n\n    _, _, retcode = execute([git_executable, 'push', 'origin', 'main'], cwd=downstream_dir)\n\n    if not retcode == 0:\n        print(\"Failed to push the git changes. Check the verbose logs for details.\")\n        sys.exit(1)\n"
  },
  "Category": "GitHub",
  "HistoryUrl": "https://github.com/OctopusDeploy/Library/commits/master/step-templates//opt/buildagent/work/75443764cd38076d/step-templates/github-push-yeoman-to-repo.json",
  "Website": "/step-templates/14ffc4b7-1cab-4f81-a835-7da41fa47123",
  "Logo": "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",
  "$Meta": {
    "Type": "ActionTemplate"
  }
}

History

Page updated on Monday, October 16, 2023