;;; GNU Guix --- Functional package management for GNU ;;; Copyright © 2015 David Thompson ;;; Copyright © 2015 Pjotr Prins ;;; Copyright © 2015, 2016 Ben Woodcroft ;;; ;;; This file is part of GNU Guix. ;;; ;;; GNU Guix is free software; you can redistribute it and/or modify it ;;; under the terms of the GNU General Public License as published by ;;; the Free Software Foundation; either version 3 of the License, or (at ;;; your option) any later version. ;;; ;;; GNU Guix is distributed in the hope that it will be useful, but ;;; WITHOUT ANY WARRANTY; without even the implied warranty of ;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;;; GNU General Public License for more details. ;;; ;;; You should have received a copy of the GNU General Public License ;;; along with GNU Guix. If not, see . (define-module (guix build ruby-build-system) #:use-module ((guix build gnu-build-system) #:prefix gnu:) #:use-module (guix build utils) #:use-module (ice-9 ftw) #:use-module (ice-9 match) #:use-module (ice-9 popen) #:use-module (ice-9 rdelim) #:use-module (ice-9 regex) #:use-module (srfi srfi-1) #:use-module (srfi srfi-26) #:export (%standard-phases ruby-build)) ;; Commentary: ;; ;; Builder-side code of the standard Ruby package build procedure. ;; ;; Code: (define (first-matching-file pattern) "Return the first file name that matches PATTERN in the current working directory." (match (find-files "." pattern) ((file-name . _) file-name) (() (error "No files matching pattern: " pattern)))) (define gnu:unpack (assq-ref gnu:%standard-phases 'unpack)) (define (gem-archive? file-name) (string-match "^.*\\.gem$" file-name)) (define* (unpack #:key source #:allow-other-keys) "Unpack the gem SOURCE and enter the resulting directory." (if (gem-archive? source) (begin (invoke "gem" "unpack" source) ;; The unpacked gem directory is named the same as the archive, ;; sans the ".gem" extension. It is renamed to simply "gem" in an ;; effort to keep file names shorter to avoid UNIX-domain socket ;; file names and shebangs that exceed the system's fixed maximum ;; length when running test suites. (let ((dir (match:substring (string-match "^(.*)\\.gem$" (basename source)) 1))) (rename-file dir "gem") (chdir "gem")) #t) ;; Use GNU unpack strategy for things that aren't gem archives. (gnu:unpack #:source source))) (define (first-gemspec) (first-matching-file "\\.gemspec$")) (define* (replace-git-ls-files #:key source #:allow-other-keys) "Many gemspec files downloaded from outside rubygems.org use `git ls-files` to list of the files to be included in the built gem. However, since this operation is not deterministic, we replace it with `find`." (when (not (gem-archive? source)) (let ((gemspec (first-gemspec))) (substitute* gemspec (("`git ls-files`") "`find . -type f |sort`") (("`git ls-files -z`") "`find . -type f -print0 |sort -z`")))) #t) (define* (extract-gemspec #:key source #:allow-other-keys) "Remove the original gemspec, if present, and replace it with a new one. This avoids issues with upstream gemspecs requiring tools such as git to generate the files list." (when (gem-archive? source) (let ((gemspec (or (false-if-exception (first-gemspec)) ;; Make new gemspec if one wasn't shipped. ".gemspec"))) (when (file-exists? gemspec) (delete-file gemspec)) ;; Extract gemspec from source gem. (let ((pipe (open-pipe* OPEN_READ "gem" "spec" "--ruby" source))) (dynamic-wind (const #t) (lambda () (call-with-output-file gemspec (lambda (out) ;; 'gem spec' writes to stdout, but 'gem build' only reads ;; gemspecs from a file, so we redirect the output to a file. (while (not (eof-object? (peek-char pipe))) (write-char (read-char pipe) out)))) #t) (lambda () (close-pipe pipe))))) #t)) (define* (build #:key source #:allow-other-keys) "Build a new gem using the gemspec from the SOURCE gem." ;; Build a new gem from the current working directory. This also allows any ;; dynamic patching done in previous phases to be present in the installed ;; gem. (invoke "gem" "build" (first-gemspec))) (define* (check #:key tests? test-target #:allow-other-keys) "Run the gem's test suite rake task TEST-TARGET. Skip the tests if TESTS? is #f." (if tests? (invoke "rake" test-target) #t)) (define* (install #:key inputs outputs (gem-flags '()) #:allow-other-keys) "Install the gem archive SOURCE to the output store item. Additional GEM-FLAGS are passed to the 'gem' invokation, if present." (let* ((ruby-version (match:substring (string-match "ruby-(.*)\\.[0-9]$" (assoc-ref inputs "ruby")) 1)) (out (assoc-ref outputs "out")) (vendor-dir (string-append out "/lib/ruby/vendor_ruby")) (gem-file (first-matching-file "\\.gem$")) (gem-file-basename (basename gem-file)) (gem-name (substring gem-file-basename 0 (- (string-length gem-file-basename) 4)))) (setenv "GEM_VENDOR" vendor-dir) (or (zero? (apply system* "gem" "install" gem-file "--local" "--ignore-dependencies" "--vendor" ;; Executables should go into /bin, not ;; /lib/ruby/gems. "--bindir" (string-append out "/bin") gem-flags)) (begin (let ((failed-output-dir (string-append (getcwd) "/out"))) (mkdir failed-output-dir) (copy-recursively out failed-output-dir)) (error "installation failed"))) ;; Remove the cached gem file as this is unnecessary and contains ;; timestamped files rendering builds not reproducible. (let ((cached-gem (string-append vendor-dir "/cache/" gem-file))) (log-file-deletion cached-gem) (delete-file cached-gem)) ;; For gems with native extensions, several Makefile-related files ;; are created that contain timestamps or other elements making ;; them not reproducible. They are unnecessary so we remove them. (when (file-exists? (string-append vendor-dir "/ext")) (for-each (lambda (file) (log-file-deletion file) (delete-file file)) (append (find-files (string-append vendor-dir "/doc") "page-Makefile.ri") (find-files (string-append vendor-dir "/extensions") "gem_make.out") (find-files (string-append vendor-dir "/ext") "Makefile")))) #t)) (define* (wrap-ruby-program prog #:key (gem-clear-paths #t) #:rest vars) "Make a wrapper for PROG. VARS should look like this: '(VARIABLE DELIMITER POSITION LIST-OF-DIRECTORIES) where DELIMITER is optional. ':' will be used if DELIMITER is not given. For example, this command: (wrap-ruby-program \"foo\" '(\"PATH\" \":\" = (\"/gnu/.../bar/bin\")) '(\"CERT_PATH\" suffix (\"/gnu/.../baz/certs\" \"/qux/certs\"))) will copy 'foo' to '.real/fool' and create the file 'foo' with the following contents: #!location/of/bin/ruby ENV['PATH'] = \"/gnu/.../bar/bin\" ENV['CERT_PATH'] = (ENV.key?('CERT_PATH') ? (ENV['CERT_PATH'] + ':') : '') + '/gnu/.../baz/certs:/qux/certs' load location/of/.real/foo This is useful for scripts that expect particular programs to be in $PATH, for programs that expect particular gems to be in the GEM_PATH. This is preferable to wrap-program, which uses a bash script, as this prevents ruby scripts from being executed with @command{ruby -S ...}. If PROG has previously been wrapped by 'wrap-ruby-program', the wrapper is extended with definitions for VARS." (define wrapped-file (string-append (dirname prog) "/.real/" (basename prog))) (define already-wrapped? (file-exists? wrapped-file)) (define (last-line port) ;; Return the last line read from PORT and leave PORT's cursor right ;; before it. (let loop ((previous-line-offset 0) (previous-line "") (position (seek port 0 SEEK_CUR))) (match (read-line port 'concat) ((? eof-object?) (seek port previous-line-offset SEEK_SET) previous-line) ((? string? line) (loop position line (+ (string-length line) position)))))) (define (export-variable lst) ;; Return a string that exports an environment variable. (match lst ((var sep '= rest) (format #f "ENV['~a'] = '~a'" var (string-join rest sep))) ((var sep 'prefix rest) (format #f "ENV['~a'] = '~a' + (ENV.key?('~a') ? ('~a' + ENV['~a']) : '')" var (string-join rest sep) var sep var)) ((var sep 'suffix rest) (format #f "ENV['~a'] = (ENV.key?('~a') ? (ENV['~a'] + '~a') : '') + '~a'" var var var sep (string-join rest sep))) ((var '= rest) (format #f "ENV['~a'] = '~a'" var (string-join rest ":"))) ((var 'prefix rest) (format #f "ENV['~a'] = '~a' + (ENV.key?('~a') ? (':' + ENV['~a']) : '')" var (string-join rest ":") var var)) ((var 'suffix rest) (format #f "ENV['~a'] = (ENV.key?('~a') ? (ENV['~a'] + ':') : '') + '~a'" var var var (string-join rest ":"))))) (if already-wrapped? ;; PROG is already a wrapper: add the new "export VAR=VALUE" lines just ;; before the last line. (let* ((port (open-file prog "r+")) (last (last-line port))) (for-each (lambda (var) (display (export-variable var) port) (newline port)) vars) (display last port) (close-port port)) ;; PROG is not wrapped yet: create a shell script that sets VARS. (let ((prog-tmp (string-append wrapped-file "-tmp"))) (mkdir-p (dirname prog-tmp)) (link prog wrapped-file) (call-with-output-file prog-tmp (lambda (port) (format port "#!~a~%~a~%~a~%load '~a'~%" (which "ruby") (string-join (map export-variable vars) "\n") ;; This ensures that if the GEM_PATH has been changed, ;; then that change will be noticed. (if gem-clear-paths "Gem.clear_paths" "") (canonicalize-path wrapped-file)))) (chmod prog-tmp #o755) (rename-file prog-tmp prog)))) (define* (wrap #:key inputs outputs #:allow-other-keys) (define (list-of-files dir) (map (cut string-append dir "/" <>) (or (scandir dir (lambda (f) (let ((s (stat (string-append dir "/" f)))) (eq? 'regular (stat:type s))))) '()))) (define bindirs (append-map (match-lambda ((_ . dir) (list (string-append dir "/bin") (string-append dir "/sbin")))) outputs)) (let* ((out (assoc-ref outputs "out")) (var `("GEM_PATH" prefix (,(string-append out "/lib/ruby/vendor_ruby") ,(getenv "GEM_PATH"))))) (for-each (lambda (dir) (let ((files (list-of-files dir))) (for-each (cut wrap-ruby-program <> var) files))) bindirs)) #t) (define (log-file-deletion file) (display (string-append "deleting '" file "' for reproducibility\n"))) (define %standard-phases (modify-phases gnu:%standard-phases (delete 'bootstrap) (delete 'configure) (replace 'unpack unpack) (add-before 'build 'extract-gemspec extract-gemspec) (add-after 'extract-gemspec 'replace-git-ls-files replace-git-ls-files) (replace 'build build) (replace 'check check) (replace 'install install) (add-after 'install 'wrap wrap))) (define* (ruby-build #:key inputs (phases %standard-phases) #:allow-other-keys #:rest args) (apply gnu:gnu-build #:inputs inputs #:phases phases args))