xref: /openbsd-src/gnu/usr.bin/perl/Porting/sync-with-cpan (revision f6aab3d83b51b91c24247ad2c2573574de475a82)
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