Home > Latest topics

Latest topics > アドオンの自動ビルドとかリリース手順の自動化とか

宣伝1。日経LinuxにてLinuxの基礎?を紹介する漫画「シス管系女子」を連載させていただいています。 以下の特設サイトにて、単行本まんがでわかるLinux シス管系女子の試し読みが可能! シス管系女子って何!? - 「シス管系女子」特設サイト

宣伝2。Firefox Hacks Rebooted発売中。本書の1/3を使って、再起動不要なアドオンの作り方のテクニックや非同期処理の効率のいい書き方などを解説しています。既刊のFirefox 3 Hacks拡張機能開発チュートリアルと併せてどうぞ。

Firefox Hacks Rebooted ―Mozillaテクノロジ徹底活用テクニック
浅井 智也 池田 譲治 小山田 昌史 五味渕 大賀 下田 洋志 寺田 真 松澤 太郎
オライリージャパン

アドオンの自動ビルドとかリリース手順の自動化とか - Mar 30, 2013

以前、update.rdf関係を半自動生成するために頑張ったことがあって、各アドオンの紹介ページのHTMLからupdate.rdf(未署名)を作るあたりまでは自動化できてたんだけど、そこ止まりになっててまだまだ手動でやらなきゃいけないことが多くて、億劫で余計にリリースが滞る……という状況になっている。

それで、これじゃいかん!と思ってもっと自動化を進めることにして、とりあえずリポジトリのmaster/HEADを元に自動でテスト用ビルドを作るようにはした

で、その知見を元にもう少し頑張って、開発版ではなくリリース版の方ももっと自動化するというチャレンジをしている。

目指しているのはこんな感じの流れ。

  1. リリース版用のタグを作ってpushする。
  2. GitHubのService Hookで自宅サーバのスクリプトを起動する。
  3. リポジトリの中に更新履歴の元になる情報があって、それを使って公開用ページのHTMLを自動更新したり、Mozilla Add-ons掲載時にコピペする用の情報を自動生成したりする。
  4. 更新されたWebページを自動公開する。

こういう風にしたいなあ、ということであれこれやってる所なんだけど、まだ完成には至っていない。なのでこのエントリは、夢を追ってる途中経過のメモということになる。もしタイトル見て期待した人がいたら、ガッカリさせちゃってすみません。一通りの処理は動くようになったっぽい。あとはほんとにちゃんと期待通り動くのかどうか、実際リリースしてみて確かめないといけない。

Service Hookを受けるスクリプトは、今はこんな感じになってる。

#!/usr/bin/env ruby
require "rubygems" # this is required only for Ruby 1.8.

require "cgi"
require "shellwords"
require "json"
require "logger"

SYNC_SCRIPT = "/home/piro/shared/or/tools/upload_nightly_xpi.sh"
RELEASE_SCRIPT = "/home/piro/shared/or/tools/release_addon.sh"
SSH_KEY = "/path/to/secret_key"
BASE_DIR = "/home/piro/shared/xul"
USER = "piro"

logger = Logger.new("/dev/null")
logger.level = Logger::INFO

cgi = CGI::new
puts "Content-Type: text/plain\n\n"
begin
  payload, = cgi.params["payload"]
  payload = JSON.parse(payload)
  project = payload["repository"]["name"]

  project_dir = File.join(BASE_DIR, Shellwords.escape(project))
  makefile = File.join(project_dir, "Makefile")
  if File.exist?(project_dir) and File.exist?(makefile)
    logger.info "build #{project}"
    command_line = "sudo -u #{USER} -H #{SYNC_SCRIPT} -i #{SSH_KEY} -b #{BASE_DIR} -d #{project_dir}"
    logger.info command_line
    logger.info `#{command_line}`
    logger.info "exit status: #{$?}"

    command_line = "cd #{project_dir}; sudo -u #{USER} -H git describe"
    logger.info command_line
    current_commit = `#{command_line}`.strip
    logger.info current_commit
    logger.info "exit status: #{$?}"
    if !current_commit.empty? && !current_commit.match(/\A.+\-[0-9]+-[0-9a-f]+\z/)
      logger.info "=> release commit"
      command_line = "sudo -u #{USER} -H #{RELEASE_SCRIPT} -i #{SSH_KEY} -b #{BASE_DIR} -n #{project}"
      logger.info `#{command_line}`
      logger.info "exit status: #{$?}"
    else
      logger.info "=> regular commit"
    end
  end

  logger.info "ok"
  p "ok"
rescue Exception => error
  logger.error error
  logger.info "ng"
  p "ng"
end

sudoを使う必要があって、visudoで以下のルールを追加している。

www-data ALL=(ALL) NOPASSWD:ALL

git describeでmaster/HEADが一番新しいタグと同じ物を指しているかどうかという事を確認して、それをトリガーにリリース用スクリプトを起動させることを目論んでるんだけど、バッククォートで標準出力の結果を取れなくて「アルェー?」ってなって詰まってる。→これはspawnで解決できた。詳細は次のエントリで。

ともかく、ここから最初にキックされるupload_nightly_xpi.shは今はこうなってる。

#!/bin/bash

# exit on error
set -e

# MX-Tools等を同じディレクトリに置いているという前提で、
# このファイルのパスを基準にして、他のツールの位置を
# 指定するため、ディレクトリのフルパスを最初に得ておく。
tools_dir=$(cd $(dirname $0) && pwd)

remote_user=piro
remote_host=piro.sakura.ne.jp
remote_dist_dir=~/www/xul/xpi/nightly
actual_dist_dir=http://piro.sakura.ne.jp/xul/xpi/nightly

# ファイルの置き場所等はオプションで得る事にする。
while getopts ab:d:i: OPT
do
  case $OPT in
    # 変更が無くても強制的にアップロードする指定。テスト用。
    "a" ) always=1 ;;
    # keyfile.pem等を置いているディレクトリのパス。
    "b" ) base_dir="$OPTARG" ;;
    # リポジトリをcloneした先のディレクトリ。
    "d" ) project_dir="$OPTARG" ;;
    # SSH接続に使う秘密鍵のパス。
    "i" ) secret_key="$OPTARG" ;; 
  esac
done

if [ "$project_dir" = '' ]; then
  echo "no project directory specified"
  exit 1
fi

if [ "$base_dir" = '' ]; then
  echo "no base directory specified"
  exit 1
fi

if [ "$secret_key" = '' ]; then
  echo "no secret key specified"
  exit 1
fi

# sedのオプションの違いを吸収しておく。
case $(uname) in
  Darwin|*BSD|CYGWIN*) sed="sed -E" ;;
  *)                   sed="sed -r" ;;
esac

# pullした時のメッセージを見て、変更があったかどうかを調べる。
cd $project_dir
pull_result=$(git pull --rebase)
updated=$(if [ "$pull_result" != "Already up-to-date." -a \
               "$pull_result" != "Current branch master is up to date." ];\
          then echo "1"; fi)
if [ "$updated" = "1" -o "$always" = "1" ]; then
  echo "project has changes. let's build it."
  "$base_dir/makexpi/make_nightly_xpi.sh" -d "$project_dir" -p "$base_dir/ k ey  file.pub"
  successfully_built=$?
  echo $successfully_built

  if [ "$successfully_built" = "0" ]; then
    echo "successfully built."
    package_name=$(cat $project_dir/Makefile | \
                   grep "PACKAGE_NAME" | \
                   head -n 1 | cut -d "=" -f 2 | \
                   $sed -e "s/\\s*//#")
    update_rdf=${package_name}.update.rdf

    # 古いupdate.rdfが残っていたら消す。
    rm -f $update_rdf

    version=$(cat nightly_version.txt)

    # ビルドした後、リポジトリの内容を元に戻しておく。
    # (次にpullする時に、未コミットの変更があるという
    # 警告が出ないようにする。)
    git reset --hard

    file=$(ls *.xpi | grep -v "_noupdate" | head -n 1)
    # キャッシュが使われる事の無いように、
    # ユニークなダウンロード用URLにする。
    update_link=${actual_dist_dir}/${file}?version=${version}

    if [ "$file" != "" -a -f $file ]; then
      echo "uploading..."
      ssh_remote=${remote_user}@${remote_host}
      # 公開先のディレクトリを作っておく。
      ssh -i $secret_key ${ssh_remote} \
          mkdir -p ${remote_dist_dir}/updateinfo
      # XPIをアップロードする。
      scp -i $secret_key ./$file \
          ${ssh_remote}:${remote_dist_dir}/
      # XPIからupdate.rdfを自動生成し、それもアップロードする。
      $tools_dir/mxtools/uhura -o $update_rdf \
        -k $base_dir/keyfile.pem $file $update_link
      scp -i $secret_key ./$update_rdf \
          ${ssh_remote}:${remote_dist_dir}/updateinfo/
      echo "done."
    fi
  else
    echo "failed to build."
    exit 1
  fi
else
  echo "project has no change."
fi

exit 0

ここでキックしてる、ナイトリービルド的なXPIを作るスクリプトのmake_nightly_xpi.shは、公開リポジトリに置いてある。前のエントリではmake_nightly_xpi.shとupload_nightly_xpi.shは1つになってたんだけど、ちょっと汎用的に使えないかなと思って分けてみた。

これが終わった後に、post-receiver.rbでどうにかしてmaster/HEADが一番新しいタグと同じかどうかを調べて、うまいこと判別できたとして、それでキックされるrelease_addon.shはこんな感じになってる。

#!/bin/bash

# exit on error
set -e

tools_dir=$(cd $(dirname "$0") && pwd)
website_dir=$(dirname "$tools_dir")

remote_user=piro
remote_host=piro.sakura.ne.jp
actual_dist_dir=http://piro.sakura.ne.jp/xul/xpi
wiki_entries_dir=~/www/wiki/entries/extensions

# ファイルの置き場所等はオプションで得る事にする。
while getopts n:b:i: OPT
do
  case $OPT in
    # keyfile.pem等を置いているディレクトリのパス。
    "b" ) base_dir="$OPTARG" ;;
    "n" ) project_name="$OPTARG" ;;
    # SSH接続に使う秘密鍵のパス。
    "i" ) secret_key="$OPTARG" ;; 
  esac
done

if [ "$project_name" = '' ]; then
  echo "no project name specified"
  exit 1
fi

if [ "$base_dir" = '' ]; then
  echo "no base directory specified"
  exit 1
fi

if [ "$secret_key" = '' ]; then
  echo "no secret key specified"
  exit 1
fi

# sedのオプションの違いを吸収しておく。
case $(uname) in
  Darwin|*BSD|CYGWIN*) sed="sed -E" ;;
  *)                   sed="sed -r" ;;
esac


main() {
  echo "starting to release $project_name"
  prepare_environment
  prepare_files
  prepare_update_info
  prepare_pages
  commit
  upload_wiki_entries
  echo "done."
}

prepare_environment() {
  # 何か変更が行われていたら、とりあえずstashに保存しておく
  cd "$website_dir"

  echo "website repository updating..."
  git stash save
  git pull --rebase

  project_dir="$base_dir/$project_name"
}

prepare_files() {
  # XPIとupdate.rdfを用意
  cd "$project_dir"
  echo "project repository updating..."
  git pull --rebase

  update_rdf="${project_name}.update.rdf"
  # 古いupdate.rdfが残っていたら消す。
  rm -f "$update_rdf"

  # XPI作成
  (cd $base_dir/makexpi &&
    git pull --rebase)
  "$base_dir/makexpi/make_release_xpi.sh" -d "$project_dir" -p "$base_dir/keyfile.pub"

  successfully_built=$?
  if [ "$successfully_built" != "0" ]; then
    echo "ERROR: failed to build release version for $project_dir"
    exit 1
  fi
}

prepare_update_info() {
  cd "$project_dir"
  echo "update info generating..."

  version=$(cat release_version.txt)
  file=$(ls *.xpi | grep -v "_noupdate" | head -n 1)
  # キャッシュが使われる事の無いように、
  # ユニークなダウンロード用URLにする。
  update_link=${actual_dist_dir}/${file}?version=${version}

  # XPIからupdate.rdfを自動生成
  $tools_dir/mxtools/uhura -o $update_rdf \
    -k $base_dir/keyfile.pem $file $update_link
}

prepare_pages() {
  echo "project pages updating..."
  "$tools_dir/update_addon_page.rb" "$project_dir"
}

commit() {
  cd "$project_dir"
  echo "saving..."

  cp "$file" "$website_dir/xul/xpi/"
  cp "$update_rdf" "$website_dir/xul/xpi/updateinfo"

  cd "$website_dir"
  git add "$website_dir/xul/xpi/$file"
  git add "$website_dir/xul/xpi/updateinfo/$update_rdf"

  git commit -a -m "Release $project_name $version"
  git push
}

upload_wiki_entries() {
  echo "wiki entries uploading..."

  en_source_path="$project_dir/last_release.en.md"
  ja_source_path="$project_dir/last_release.ja.md"
  en_entry_dist_dir="$wiki_entries_dir/$project_name/en-US"
  ja_entry_dist_dir="$wiki_entries_dir/$project_name/ja"

  ssh_remote=${remote_user}@${remote_host}

  if [ -f $en_source_path ]; then
    ssh -i $secret_key ${ssh_remote} \
        mkdir -p $en_entry_dist_dir
    scp -i $secret_key $project_dir/last_release.en.md \
        ${ssh_remote}:${en_entry_dist_dir}/${version}.txt
  fi
  if [ -f $ja_source_path ]; then
    ssh -i $secret_key ${ssh_remote} \
        mkdir -p $ja_entry_dist_dir
    scp -i $secret_key $project_dir/last_release.ja.md \
        ${ssh_remote}:${ja_entry_dist_dir}/${version}.txt
  fi
}

main

これだけで全部をまかなってるわけではなくて、XPIの生成とHTMLの更新は別のスクリプトに分けてる。

XPIは、make_nightly_xpi.shを元にしたmake_release_xpi.shで作ってる。こいつはgit describeの結果を元にして一番新しいタグをチェックアウトしてビルドする、という事だけを担当する。

HTMLは、以下の内容の update_addon_page.rb でやってる。

#!/usr/bin/env ruby
require "rubygems" # this is required only for Ruby 1.8.
require "redcarpet"
require "nokogiri"
require "fileutils"
require "date"

class History
  VERSION_ITEMS_EXPRESSION = "/html/body/ul/li".freeze
  VERSION_PARTS_EXPRESSION = "child::node()[not(local-name() = 'dl' or local- n   ame() = 'ul' or local-name() = 'ol')]".freeze
  VERSION_SUBITEMS_EXPRESSION = "child::*[local-name() = 'dl' or local-name() =    ' ul' or local-name() = 'ol']".freeze

  def initialize(params)
    @project_name = params[:project_name]
    @source_path = params[:source_path]
  end

  def source
    @source ||= File.read(@source_path)
  end

  def to_html
    return @html unless @html.nil?

    markdown = Redcarpet::Markdown.new(Redcarpet::Render::XHTML)
    html = markdown.render(source)
    html.gsub!(/\n\n+/, "\n")

    result = []
    document = Nokogiri::HTML(html)
    document.xpath(VERSION_ITEMS_EXPRESSION).each do |item|
      version = item.xpath(VERSION_PARTS_EXPRESSION).collect do |version_part|
        version_part.to_xhtml(:encoding => "UTF-8").sub(/\A\s+|\s+\z/, "")
      end
      result << "<dt>#{version.join("")}</dt>"

      sub_items = item.xpath(VERSION_SUBITEMS_EXPRESSION).collect do |sub _item|
        sub_item.to_xhtml(:encoding => "UTF-8").
          gsub(/<ul><li>/, "<ul>\n<li>").
          gsub(/^<li>/, "\t<li>")
      end
      result << "<dd>#{sub_items.join("")}</dd>".gsub(/^/, "\t")
    end
    @html = result.join("\n").gsub(/^/, "\t\t\t")
  end

  def last_release
    return @last_release unless @last_release.nil?

    releases = source.split(/^ - /)
    releases.each do |release|
      if release.match(/\A[0-9\.]+/)
        @last_release = release
        break
      end
    end

    @last_release
  end

  def last_release_version
    @last_release_version ||= last_release.split("\n").first.strip
  end

  def last_release_topics
    return @last_release_topics unless @last_release_topics.nil?
    @last_release_topics = last_release.split("\n")[1..-1].join("\n")
    @last_release_topics.gsub!(/^\s+\*/, " *")
  end

  def meta_creation_date
    return @meta_creation_date unless @meta_creation_date.nil?
    date = DateTime.now.strftime("%m/%e/%Y %H:%M:%S")
    date.sub!(/^0/, "")
    @meta_creation_date = date
  end

  def last_release_wiki_entry
    return @last_release_wiki_entry unless @last_release_wiki_entry.nil?
    contents = []
    contents << "#{@project_name} #{last_release_version}"
    contents << "meta-creation_date: #{meta_creation_date}"
    contents << ""
    contents << ""
    contents << ""
    contents << last_release_topics
    @last_release_wiki_entry = contents.join("\n")
  end

  def last_release_html
    return @last_release_html unless @last_release_html.nil?

    markdown = Redcarpet::Markdown.new(Redcarpet::Render::XHTML)
    html = markdown.render(last_release_topics)
    html.gsub!(/\n/, "")
    html = "#{last_release_version}:#{html}"
    @last_release_html = html
  end
end

class PageUpdater
  def initialize(params)
    @project_dir = params[:project_dir]
    @language = params[:language]
  end

  def tools_dir
    @tools_dir ||= File.dirname(File.expand_path(__FILE__))
  end

  def website_dir
    @website_dir ||= File.dirname(tools_dir)
  end

  def project_name
    return @project_name unless @project_name.nil?

    makefile_path = "#{@project_dir}/Makefile"
    @project_name = `cat "#{makefile_path}" | \
                     grep "PACKAGE_NAME" | \
                     head -n 1 | \
                     cut -d "=" -f 2`.strip
    @project_name
  end

  def version
    @version ||= File.read("#{@project_dir}/release_version.txt").strip
  end

  def history_source_path
    "#{@project_dir}/history.#{@language}.md"
  end

  def history
    @history ||= History.new(:source_path => history_source_path,
                             :project_name => project_name)
  end

  def last_release_path_base
    "#{@project_dir}/last_release.#{@language}"
  end

  def html_suffix
    return @html_suffix unless @html_suffix.nil?
    suffix = ".html"
    suffix = "#{suffix}.#{@language}" if @language != "ja"
    @html_suffix = suffix
  end

  def page_path_base
    "#{website_dir}/xul"
  end

  def version_page_path
    return @version_page_path unless @version_page_path.nil?
    path = "#{page_path_base}/_#{project_name}#{html_suffix}"
    unless File.exist?(path)
      path = "#{page_path_base}/#{project_name}/index#{html_suffix}"
    end
    @version_page_path = path
  end

  def history_page_path
    return @history_page_path unless @history_page_path.nil?
    path = "#{page_path_base}/_#{project_name}#{html_suffix}"
    unless File.exist?(path)
      path = "#{page_path_base}/#{project_name}/history#{html_suffix}"
    end
    unless File.exist?(path)
      path = "#{page_path_base}/#{project_name}/index#{html_suffix}"
    end
    @history_page_path = path
  end

  def apply_version(source)
    before, middle = source.split(/<!--\s*BEGIN VERSION\s*-->/)
    middle, after  = middle.split(/<!--\s*END VERSION\s*-->/)
    "#{before}<!--BEGIN VERSION-->#{version}<!--END VERSION-->#{after}"
  end

  def apply_history(source)
    before, middle = source.split(/<!--\s*BEGIN HISTORY ITEMS\s*-->/)
    middle, after  = middle.split(/<!--\s*END HISTORY ITEMS\s*-->/)
    "#{before}<!--BEGIN HISTORY ITEMS-->\n#{history.to_html}\t\t<!--END HI STO RY  I TEMS-->#{after}"
  end

  def write_to(contents, path)
    File.open(path, "w") do |file|
     file.puts(contents)
    end
  end

  def update
    if version_page_path == history_page_path
      page = File.read(version_page_path)
      page = apply_version(page)
      page = apply_history(page)
      write_to(page, version_page_path)
    else
      version_page = File.read(version_page_path)
      version_page = apply_version(version_page)
      write_to(version_page, version_page_path)

      history_page = File.read(history_page_path)
      history_page = apply_history(history_page)
      write_to(history_page, history_page_path)
    end

    write_to(history.last_release_wiki_entry, "#{last_release_path_base}.md")
    write_to(history.last_release_html, "#{last_release_path_base}.html")
  end
end

project_dir = ARGV.first
PageUpdater.new(:project_dir => project_dir, :language => "ja").update
PageUpdater.new(:project_dir => project_dir, :language => "en").update

こいつが、HTMLの更新と、Mozilla Add-ons用の更新履歴のコード片の生成までをやってくれる。予定。

ということで、Service Hookを受けたときにタグを取れてない、という所だけどうにかなれば割と動きそうな印象なんだけど、そうこうしてるうちに肉リリースの期限が終わってしまったので、続きはまた1ヶ月後くらいになりそうな予感がしています。

RubyとBashを行ったり来たりしてて、自分でもなんやねんこれ感がすごいある。あとコードがやっつけすぎるのも、どうなん感ある。それでも公開しちゃうフルディスクロージャ戦略です。

4月1日追記。なぜpost-receiver.rbでバッククォートでgit describeした結果を取得できないのかをすとうさんに教えて頂きました。このpost-receiver.rbはCGIスクリプトとして動いているため標準入出力がCGIの口と繋がっていて、そこから起動された外部のコマンド(子プロセス)の標準入出力もそれを引き継ぐので、git describeの結果が直接クライアント(GitHubのUserAgent)の方に返されてしまっているのだろう、とのことです。Ruby 1.9系で標準入出力を指定してspawnしてProcess.waitpidで終了を待つとよいだろう……とのアドバイスを頂きましたので、あとで試してみようと思います。続き書きました

2015年追記。Raspberry PiのLubuntu(Ubuntu)でこれを動かすようにするにあたって必要だった手順。

  1. 必要なパッケージのインストール。 sudo apt-get install apache2 ruby2.0 cpanminus build-essential
  2. スクリプトを ~username/public_html/ 以下に置いているので、 このパスを有効化。sudo a2enmod userdir
    • .htaccessOptionsを制御する必要があるので、vi /etc/apache2/mods-enabled/userdir.confAllowOverrideOptionsを追加する。
  3. CGIスクリプトとして動かすので、CGIを有効化。sudo a2enmod cgi
  4. Apache2を再起動。sudo service apache2 restart
  5. uhraを動かすために必要なCPANモジュールをインストール。sudo cpan install Convert::ASN1 XML::Parser RDF::Core
分類:Mozilla > 拡張機能, , , , , , 時刻:04:08 | Comments/Trackbacks (0) | Edit

Comments/Trackbacks

TrackBack ping me at


の末尾に2014年1月19日時点の日本の首相のファミリーネーム(ローマ字で回答)を繋げて下さい。例えば「noda」なら、「2013-03-30_autobuild.trackbacknoda」です。これは機械的なトラックバックスパムを防止するための措置です。

Post a comment

writeback message: Ready to post a comment.

2014年1月19日時点の日本の首相のファミリーネーム(ひらがなで回答)

Powered by blosxom 2.0 + starter kit
Home

カテゴリ一覧

過去の記事

1999.2~2005.8

最近のつぶやき

オススメ

Mozilla Firefox ブラウザ無料ダウンロード