1#!/usr/bin/env perl 2 3=head1 NAME 4 5Porting/sync-with-cpan - Synchronize with CPAN distributions 6 7=head1 SYNOPSIS 8 9 sh ./Configure 10 perl Porting/sync-with-cpan <module> 11 12where C<module> is the name it appears in the C<%Modules> hash 13of F<Porting/Maintainers.pl> 14 15=head1 DESCRIPTION 16 17Script to help out with syncing cpan distros. 18 19Does the following: 20 21=over 4 22 23=item * 24 25Fetches the package list from CPAN. Finds the current version of the given 26package. [1] 27 28=item * 29 30Downloads the relevant tarball; unpacks the tarball. [1] 31 32=item * 33 34Clean out the old directory (C<git clean -dfx>) 35 36=item * 37 38Moves the old directory out of the way, moves the new directory in place. 39 40=item * 41 42Restores any F<.gitignore> file. 43 44=item * 45 46Removes files from C<@IGNORE> and C<EXCLUDED> 47 48=item * 49 50C<git add> any new files. 51 52=item * 53 54C<git rm> any files that are gone. 55 56=item * 57 58Remove the +x bit on files in F<t/> 59 60=item * 61 62Remove the +x bit on files that don't have it enabled in the current dir 63 64=item * 65 66Restore files mentioned in C<CUSTOMIZED> 67 68=item * 69 70Updates the contents of F<MANIFEST> 71 72=item * 73 74Runs a C<make> (assumes a configure has been run) 75 76=item * 77 78Cleans up 79 80=item * 81 82Runs tests for the package 83 84=item * 85 86Runs the porting tests 87 88=back 89 90[1] If the C<--tarball> option is given, then CPAN is not consulted. 91C<--tarball> should be the path to the tarball; the version is extracted 92from the filename -- but can be overwritten by the C<--version> option. 93 94=head1 OPTIONS 95 96=over 4 97 98=item C<--jobs> I<N> 99 100When running C<make>, pass a C<< -jI<N> >> option to it. 101 102=back 103 104=head1 TODO 105 106=over 4 107 108=item * 109 110Update F<Porting/Maintainers.pl> 111 112=item * 113 114Optional, run a full test suite 115 116=item * 117 118Handle complicated C<FILES> 119 120=back 121 122This is an initial version; no attempt has been made yet to make this 123portable. It shells out instead of trying to find a Perl solution. 124In particular, it assumes git, perl, and make 125to be available. 126 127=cut 128 129 130package Maintainers; 131 132use 5.010; 133 134use strict; 135use warnings; 136use Getopt::Long; 137use Archive::Tar; 138use File::Basename qw( basename ); 139use File::Path qw( remove_tree ); 140use File::Find; 141use File::Spec::Functions qw( tmpdir rel2abs ); 142use Config qw( %Config ); 143 144$| = 1; 145 146use constant WIN32 => $^O eq 'MSWin32'; 147 148die "This does not look like a top level directory" 149 unless -d "cpan" && -d "Porting"; 150 151# Check that there's a Makefile, if needed; otherwise, we'll do most of our 152# work only to fail when we try to run make, and the user will have to 153# either unpick everything we've done, or do the rest manually. 154die "Please run Configure before using $0\n" 155 if !WIN32 && !-f "Makefile"; 156 157our @IGNORABLE; 158our %Modules; 159 160use autodie; 161 162require "./Porting/Maintainers.pl"; 163 164my $MAKE_LOG = 'make.log'; 165 166my %IGNORABLE = map {$_ => 1} @IGNORABLE; 167 168my $tmpdir = tmpdir(); 169 170my $package = "02packages.details.txt"; 171my $package_url = "http://www.cpan.org/modules/$package"; 172my $package_file = "$tmpdir/$package"; # this is a cache 173 174my @problematic = ( 175 'podlators', # weird CUSTOMIZED section due to .PL files 176); 177 178 179sub usage 180{ 181 my $err = shift and select STDERR; 182 print "Usage: $0 <module-or-dist> [args]\n"; 183 exit $err; 184} 185 186GetOptions ('tarball=s' => \my $tarball, 187 'version=s' => \my $version, 188 'jobs=i' => \my $make_jobs, 189 force => \my $force, 190 help => sub { usage 0; }, 191 ) or die "Failed to parse arguments"; 192 193usage 1 unless @ARGV == 1 || @ARGV == 2; 194 195sub find_type_f { 196 my @res; 197 find( { no_chdir => 1, wanted => sub { 198 my $file= $File::Find::name; 199 return unless -f $file; 200 push @res, $file 201 }}, @_ ); 202 @res 203}; 204 205# Equivalent of `chmod a-x` 206sub de_exec { 207 my ($filename) = @_; 208 my $mode = (stat $filename)[2] & 0777; 209 if ($mode & 0111) { # exec-bit set 210 chmod $mode & 0666, $filename; 211 } 212} 213 214# Equivalent of `chmod +w` 215sub make_writable { 216 my ($filename) = @_; 217 my $mode = (stat $filename)[2] & 0777; 218 if (!($mode & 0222)) { # not writable 219 chmod $mode | (0222 & ~umask), $filename; 220 } 221} 222 223sub make { 224 my @args= @_; 225 unshift @args, "-j$make_jobs" if defined $make_jobs; 226 if (WIN32) { 227 chdir "Win32"; 228 system "$Config{make} @args> ..\\$MAKE_LOG 2>&1" 229 and die "Running make failed, see $MAKE_LOG"; 230 chdir '..'; 231 } else { 232 system "$Config{make} @args> $MAKE_LOG 2>&1" 233 and die "Running make failed, see $MAKE_LOG"; 234 }; 235}; 236 237my ($module) = shift; 238 239my $info = $Modules{$module}; 240if (!$info) { 241 # Maybe the user said "Test-Simple" instead of "Test::Simple", or 242 # "IO::Compress" instead of "IO-Compress". See if we can fix it up. 243 my $guess = $module; 244 s/-/::/g or s/::/-/g for $guess; 245 $info = $Modules{$guess} or die <<"EOF"; 246Cannot find module $module. 247The available options are listed in the %Modules hash in Porting/Maintainers.pl 248EOF 249 say "Guessing you meant $guess instead of $module"; 250 $module = $guess; 251} 252 253if ($info->{CUSTOMIZED}) { 254 print <<"EOF"; 255$module has a CUSTOMIZED entry in Porting/Maintainers.pl. 256 257This program's behaviour is to copy every CUSTOMIZED file into the version 258of the module being imported. But that might not be the right thing: in some 259cases, the new CPAN version will supersede whatever changes had previously 260been made in blead, so it would be better to import the new CPAN files. 261 262If you've checked that the CUSTOMIZED versions are still correct, you can 263proceed now. Otherwise, you should abort and investigate the situation. If 264the blead customizations are no longer needed, delete the CUSTOMIZED entry 265for $module in Porting/Maintainers.pl (and you'll also need to regenerate 266t/porting/customized.dat in that case; see t/porting/customized.t). 267 268EOF 269 print "Hit return to continue; ^C to abort "; <STDIN>; 270} 271 272my $distribution = $$info {DISTRIBUTION}; 273 274my @files = glob $$info {FILES}; 275if (!-d $files [0] || grep { $_ eq $module } @problematic) { 276 say "This looks like a setup $0 cannot handle (yet)"; 277 unless ($force) { 278 say "Will not continue without a --force option"; 279 exit 1; 280 } 281 say "--force is in effect, so we'll soldier on. Wish me luck!"; 282} 283 284use Cwd 'cwd'; 285my $orig_pwd = cwd(); 286 287chdir "cpan"; 288 289my $pkg_dir = $files[0]; 290 $pkg_dir =~ s!.*/!!; 291 292my ($old_version) = $distribution =~ /-([0-9.]+(?:-TRIAL[0-9]*)?)\.tar\.gz/; 293 294sub wget { 295 my ($url, $saveas) = @_; 296 my $ht_res; 297 eval { 298 require IO::Socket::SSL; 299 require Net::SSLeay; 300 require HTTP::Tiny; 301 my $http = HTTP::Tiny->new(); 302 $ht_res = $http->mirror( $url => $saveas ); 303 1; 304 } or 305 # Try harder to download the file 306 # Some system do not have wget. Fall back to curl if we do not 307 # have it. On Windows, `which wget` is not going to work, so 308 # just use wget, as this script has always done. 309 WIN32 || -x substr(`which wget`, 0, -1) 310 ? system wget => $url, '-qO', $saveas 311 : system curl => $url, '-sSo', $saveas; 312 313 # We were able to use HTTP::Tiny and it didn't have fatal errors, 314 # but we failed the request 315 if ( $ht_res && ! $ht_res->{'success'} ) { 316 die "Cannot retrieve file: $url\n" . 317 sprintf "Status: %s\nReason: %s\nContent: %s\n", 318 map $_ // '(unavailable)', @{$ht_res}{qw< status reason content >}; 319 } 320} 321 322# 323# Find the information from CPAN. 324# 325my $new_file; 326my $new_version; 327if (defined $tarball) { 328 $tarball = rel2abs( $tarball, $orig_pwd ) ; 329 die "Tarball $tarball does not exist\n" if !-e $tarball; 330 die "Tarball $tarball is not a plain file\n" if !-f _; 331 $new_file = $tarball; 332 $new_version = $version // ($new_file =~ /-([0-9._]+(?:-TRIAL[0-9]*)?)\.tar\.gz/) [0]; 333 die "Blead and that tarball both have version $new_version of $module\n" 334 if $new_version eq $old_version; 335} 336else { 337 # 338 # Poor man's cache 339 # 340 unless (-f $package_file && -M $package_file < 1) { 341 wget $package_url, $package_file; 342 } 343 344 my $cpan_mod = $info->{MAIN_MODULE} // $module; 345 open my $fh, '<', $package_file; 346 (my $new_line) = grep {/^\Q$cpan_mod\E /} <$fh> # Yes, this needs a lot of memory 347 or die "Cannot find $cpan_mod on CPAN\n"; 348 (undef, $new_version, my $new_path) = split ' ', $new_line; 349 if (defined $version) { 350 $new_path =~ s/-$new_version\./-$version\./; 351 $new_version = $version; 352 } 353 $new_file = (split '/', $new_path) [-1]; 354 355 die "The latest version of $module is $new_version, but blead already has it\n" 356 if $new_version eq $old_version; 357 358 my $url = "https://cpan.metacpan.org/authors/id/$new_path"; 359 say "Fetching $url"; 360 # 361 # Fetch the new distro 362 # 363 wget $url, $new_file; 364} 365 366my $old_dir = "$pkg_dir-$old_version"; 367 368say "Cleaning out old directory"; 369system git => 'clean', '-dfxq', $pkg_dir; 370 371say "Unpacking $new_file"; 372Archive::Tar->extract_archive( $new_file ); 373 374(my $new_dir = basename($new_file)) =~ s/\.tar\.gz//; 375# ensure 'make' will update all files 376my $t= time; 377for my $file (find_type_f($new_dir)) { 378 make_writable($file); # for convenience if the user later edits it 379 utime($t,$t,$file); 380}; 381 382say "Renaming directories"; 383rename $pkg_dir => $old_dir; 384 385say "Creating new package directory"; 386mkdir $pkg_dir; 387 388say "Populating new package directory"; 389my $map = $$info {MAP}; 390my @EXCLUDED_QR; 391my %EXCLUDED_QQ; 392if ($$info {EXCLUDED}) { 393 foreach my $entry (@{$$info {EXCLUDED}}) { 394 if (ref $entry) {push @EXCLUDED_QR => $entry} 395 else {$EXCLUDED_QQ {$entry} = 1} 396 } 397} 398 399FILE: for my $file ( find_type_f( $new_dir )) { 400 my $old_file = $file; 401 $file =~ s{^\Q$new_dir\E/}{}; 402 403 next if $EXCLUDED_QQ{$file}; 404 for my $qr (@EXCLUDED_QR) { 405 next FILE if $file =~ $qr; 406 } 407 408 if ( $map ) { 409 for my $key ( sort { length $b <=> length $a } keys %$map ) { 410 my $val = $map->{$key}; 411 last if $file =~ s/^$key/$val/; 412 } 413 } 414 else { 415 $file = $files[0] . '/' . $file; 416 } 417 418 if ( $file =~ m{^cpan/} ) { 419 $file =~ s{^cpan/}{}; 420 } 421 else { 422 $file = '../' . $file; 423 } 424 425 my $prefix = ''; 426 my @parts = split '/', $file; 427 pop @parts; 428 for my $part (@parts) { 429 $prefix .= '/' if $prefix; 430 $prefix .= $part; 431 mkdir $prefix unless -d $prefix; 432 } 433 434 rename $old_file => $file; 435} 436remove_tree( $new_dir ); 437 438if (-f "$old_dir/.gitignore") { 439 say "Restoring .gitignore"; 440 system git => 'checkout', "$pkg_dir/.gitignore"; 441} 442 443my @new_files = find_type_f( $pkg_dir ); 444@new_files = grep {$_ ne $pkg_dir} @new_files; 445s!^[^/]+/!! for @new_files; 446my %new_files = map {$_ => 1} @new_files; 447 448my @old_files = find_type_f( $old_dir ); 449@old_files = grep {$_ ne $old_dir} @old_files; 450s!^[^/]+/!! for @old_files; 451my %old_files = map {$_ => 1} @old_files; 452 453my @delete; 454my @commit; 455my @gone; 456FILE: 457foreach my $file (@new_files) { 458 next if -d "$pkg_dir/$file"; # Ignore directories. 459 next if $old_files {$file}; # It's already there. 460 if ($IGNORABLE {$file}) { 461 push @delete => $file; 462 next; 463 } 464 push @commit => $file; 465} 466foreach my $file (@old_files) { 467 next if -d "$old_dir/$file"; 468 next if $new_files {$file}; 469 push @gone => $file; 470} 471 472# 473# Find all files with an exec bit 474# 475my @exec = find_type_f( $pkg_dir ); 476my @de_exec; 477foreach my $file (@exec) { 478 # Remove leading dir 479 $file =~ s!^[^/]+/!!; 480 if ($file =~ m!^t/!) { 481 push @de_exec => $file; 482 next; 483 } 484 # Check to see if the file exists; if it doesn't and doesn't have 485 # the exec bit, remove it. 486 if ($old_files {$file}) { 487 unless (-x "$old_dir/$file") { 488 push @de_exec => $file; 489 } 490 } 491} 492 493# 494# No need to change the +x bit on files that will be deleted. 495# 496if (@de_exec && @delete) { 497 my %delete = map {+"$pkg_dir/$_" => 1} @delete; 498 @de_exec = grep {!$delete {$_}} @de_exec; 499} 500 501# 502# Mustn't change the +x bit on files that are whitelisted 503# 504if (@de_exec) { 505 my %permitted = map { (my $x = $_) =~ tr/\n//d; $x => 1 } grep !/^#/, 506 do { local @ARGV = '../Porting/exec-bit.txt'; <> }; 507 @de_exec = grep !$permitted{"cpan/$pkg_dir/$_"}, @de_exec; 508} 509 510say "unlink $pkg_dir/$_" for @delete; 511say "git add $pkg_dir/$_" for @commit; 512say "git rm -f $pkg_dir/$_" for @gone; 513say "chmod a-x $pkg_dir/$_" for @de_exec; 514 515print "Hit return to continue; ^C to abort "; <STDIN>; 516 517unlink "$pkg_dir/$_" for @delete; 518system git => 'add', "$pkg_dir/$_" for @commit; 519system git => 'rm', '-f', "$pkg_dir/$_" for @gone; 520de_exec( "$pkg_dir/$_" ) for @de_exec; 521 522# 523# Restore anything that is customized. 524# We don't really care whether we've deleted the file - since we 525# do a git restore, it's going to be resurrected if necessary. 526# 527if ($$info {CUSTOMIZED}) { 528 say "Restoring customized files"; 529 foreach my $file (@{$$info {CUSTOMIZED}}) { 530 system git => "checkout", "$pkg_dir/$file"; 531 } 532} 533 534chdir ".."; 535if (@commit || @gone) { 536 say "Fixing MANIFEST"; 537 my $MANIFEST = "MANIFEST"; 538 my $MANIFEST_NEW = "$MANIFEST.new"; 539 540 open my $orig, "<", $MANIFEST 541 or die "Failed to open $MANIFEST for reading: $!\n"; 542 open my $new, ">", $MANIFEST_NEW 543 or die "Failed to open $MANIFEST_NEW for writing: $!\n"; 544 my %gone = map +("cpan/$pkg_dir/$_" => 1), @gone; 545 while (my $line = <$orig>) { 546 my ($file) = $line =~ /^(\S+)/ 547 or die "Can't parse MANIFEST line: $line"; 548 print $new $line if !$gone{$file}; 549 } 550 551 say $new "cpan/$pkg_dir/$_" for @commit; 552 553 close $new or die "Can't close $MANIFEST: $!\n"; 554 555 system $^X => "Porting/manisort", '--quiet', "--output=$MANIFEST", $MANIFEST_NEW; 556 unlink $MANIFEST_NEW 557 or die "Can't delete temporary $MANIFEST_NEW: $!\n"; 558} 559 560 561print "Running a make and saving its output to $MAKE_LOG ... "; 562# Prepare for running (selected) tests 563make 'test-prep'; 564print "done\n"; 565 566# The build system installs code from CPAN dists into the lib/ directory, 567# creating directories as needed. This means that the cleaning-related rules 568# in the Makefile need to know which directories to clean up. The Makefile 569# is generated by Configure from Makefile.SH, so *that* file needs the list 570# of directories. regen/lib_cleanup.pl is capable of automatically updating 571# the contents of Makefile.SH (and win32/Makefile, which needs similar but 572# not identical lists of directories), so we can just run that (using the 573# newly-built Perl, as is done with the regen programs run by "make regen"). 574# 575# We do this if any files at all have been added or deleted, regardless of 576# whether those changes result in any directories being added or deleted, 577# because the alternative would be to replicate the regen/lib_cleanup.pl 578# logic here. That's fine, because regen/lib_cleanup.pl is idempotent if run 579# repeatedly. 580if (@commit || @gone) { 581 say "Running regen/lib_cleanup.pl to handle potential added/deleted dirs"; 582 my $exe_dir = WIN32 ? ".\\" : './'; 583 system "${exe_dir}perl$Config{_exe}", "-Ilib", "regen/lib_cleanup.pl" 584 and die "regen/lib_cleanup.pl failed\n"; 585} 586 587# 588# Must clean up, or else t/porting/FindExt.t will fail. 589# Note that we can always retrieve the original directory with a git checkout. 590# 591print "About to clean up; hit return or abort (^C) "; <STDIN>; 592 593remove_tree( "cpan/$old_dir" ); 594unlink "cpan/$new_file" unless $tarball; 595 596# 597# Run the tests. First the test belonging to the module, followed by the 598# tests in t/porting 599# 600chdir "t"; 601say "Running module tests"; 602my @test_files = grep { /\.t$/ } find_type_f( "../cpan/$pkg_dir" ); 603my $exe_dir = WIN32 ? "..\\" : './'; 604my $output = `${exe_dir}perl$Config{_exe} TEST @test_files`; 605unless ($output =~ /All tests successful/) { 606 say $output; 607 exit 1; 608} 609 610print "Running tests in t/porting "; 611my @tests = glob 'porting/*.t'; 612chomp @tests; 613my @failed; 614foreach my $t (@tests) { 615 my @not = grep {!/# TODO/ } 616 grep { /^not/ } 617 `${exe_dir}perl -I../lib -I.. $t`; 618 print @not ? '!' : '.'; 619 push @failed => $t if @not; 620} 621print "\n"; 622say "Failed tests: @failed" if @failed; 623 624 625chdir '..'; 626 627open my $Maintainers_pl, '<', 'Porting/Maintainers.pl'; 628open my $new_Maintainers_pl, '>', 'Maintainers.pl'; 629 630my $found; 631my $in_mod_section; 632while (<$Maintainers_pl>) { 633 if (!$found) { 634 if ($in_mod_section) { 635 if (/DISTRIBUTION/) { 636 if (s/\Q$old_version/$new_version/) { 637 $found = 1; 638 } 639 } 640 641 if (/^ \}/) { 642 $in_mod_section = 0; 643 } 644 } 645 646 if (/\Q$module/) { 647 $in_mod_section = 1; 648 } 649 } 650 651 print $new_Maintainers_pl $_; 652} 653 654if ($found) { 655 say "Successfully updated Maintainers.pl"; 656 unlink 'Porting/Maintainers.pl'; 657 rename 'Maintainers.pl' => 'Porting/Maintainers.pl'; 658 chmod 0755 => 'Porting/Maintainers.pl'; 659} 660else { 661 say "Could not update Porting/Maintainers.pl."; 662 say "Make sure you update this by hand before committing."; 663} 664 665print <<"EOF"; 666 667======================================================================= 668 669$module is now at version $new_version 670Next, you should run "make minitest" and then "make test". 671 672Minitest uses miniperl, which does not support XS modules. The full test 673suite uses perl, which does. Minitest can fail - e.g. if a cpan module 674has added an XS dependency - even if the full test suite passes just fine. 675 676Hopefully all will complete successfully, but if not, you can make any 677changes you need to get the tests to pass. Don't forget that you'll need 678a "CUSTOMIZED" entry in Porting/Maintainers.pl if you change any of the 679files under cpan/$pkg_dir. 680 681Once all tests pass, you can "git add -u" and "git commit" the changes 682with a message along the lines of "Update Foo::Bar to v1.234". 683 684EOF 685 686__END__ 687