File Coverage

File:apply.pl
Coverage:44.8%

linestmtbrancondsubtimecode
1#!/usr/bin/env perl
2
2
36795
":" || q@<<"=END_OF_PERL"@;
3
4
2
2
2
2470
907
53
use Symbol 'gensym';
5
2
2
2
305
2445
48
use IPC::Open3;
6
2
2
2
4
1
54
use File::Basename qw(dirname);
7
2
2
2
2
3
38
use File::Path qw(make_path);
8
2
2
2
290
631
61
use File::Spec::Functions qw(catfile path);
9
2
2
2
611
15370
57
use File::Temp qw/ tempfile tempdir /;
10
2
2
2
652
12072
53
use JSON::PP;
11
2
2
2
6
1
47069
use warnings;
12
13
2
4
my @safe_path = qw(
14    /opt/homebrew/bin
15    /opt/homebrew/sbin
16    /usr/local/bin
17    /usr/bin
18    /bin
19    /usr/sbin
20    /sbin
21);
22
23
2
43
my $bin = glob("~/bin");
24
2
6
push @safe_path, $bin if -d $bin;
25
26
2
2
my $ua = 'check-spelling-agent/0.0.3';
27
28
2
9
$ENV{'PATH'} = join ':', @safe_path unless defined $ENV{SYSTEMROOT};
29
30sub check_exists_command {
31
6
3
    my ($program) = @_;
32
33
6
4
    my @path = path;
34
6
29
    my @pathext = ('');
35
36
6
6
    if ($^O eq 'MSWin32') {
37
0
0
0
0
        push @pathext, map { lc } split /;/, $ENV{PATHEXT};
38    }
39
40
6
3
    for my $dir (@path) {
41
24
11
        for my $suffix (@pathext) {
42
24
32
            my $f = catfile $dir, "$program$suffix";
43
24
72
            return $f if -x $f;
44        }
45    }
46}
47
48sub needs_command_because {
49
6
5
    my ($program, $reason) = @_;
50
6
5
    return if check_exists_command($program);
51
0
0
    die 'Please install `'.$program.'` - it is needed to '.$reason;
52}
53
54sub check_basic_tools {
55
2
3
    needs_command_because('git', 'interact with git repositories');
56
2
2
    needs_command_because('curl', 'download other tools');
57
2
11
    needs_command_because('gh', 'interact with github');
58    #needs_command_because('magic-magic', 'debugging');
59}
60
61sub download_with_curl {
62
2
2
    my ($url, $dest, $flags) = @_;
63
2
2
    $flags = '-fsL' unless defined $flags;
64
2
71712
    system('curl',
65        '--connect-timeout', 3,
66        '-A', $ua,
67        $flags,
68        '-o', $dest,
69        $url
70    );
71}
72
73sub tempfile_name {
74
2
2
    my ($fh, $filename) = tempfile();
75
2
361
    close $fh;
76
2
4
    return $filename;
77}
78
79sub strip_comments {
80
4
6
    my ($file) = @_;
81
4
13
    my ($fh, $filename) = tempfile();
82
4
738
    open INPUT, '<', $file;
83
4
42
    while (<INPUT>) {
84
2564
1568
        next if /^\s*(?:#.*)/;
85
2516
1765
        print $fh $_;
86    }
87
4
11
    close INPUT;
88
4
32
    close $fh;
89
4
10
    return $filename;
90}
91
92sub capture_system {
93
16
24
    my @args = @_;
94
16
52
    my $pid = open3(my $child_in, my $child_out, my $child_err = gensym, @args);
95
16
21135
    my (@err, @out);
96
16
13397
    while (my $output = <$child_out>) {
97
20
689
        push @out, $output;
98    }
99
16
317
    while (my $error = <$child_err>) {
100
6
23
        push @err, $error;
101    }
102
16
110
    waitpid( $pid, 0 );
103
16
54
    my $child_exit_status = $?;
104
16
53
    my $output_joined = join '', @out;
105
16
25
    my $error_joined = join '', @err;
106
16
313
    return ($output_joined, $error_joined, $child_exit_status);
107}
108
109sub capture_merged_system {
110
0
0
    my ($output_joined, $error_joined, $child_exit_status) = capture_system(@_);
111
0
0
    my $joiner = ($output_joined ne '') ? "\n" : '';
112
0
0
    return ($output_joined.$joiner.$error_joined, $child_exit_status);
113}
114
115sub compare_files {
116
2
11
    my ($one, $two) = @_;
117
2
16
    my $one_stripped = strip_comments($one);
118
2
2
    my $two_stripped = strip_comments($two);
119
2
1
    my $exit;
120
2
6
    (undef, undef, $exit) = capture_system(
121            'diff',
122            '-qwB',
123            $one_stripped, $two_stripped
124        );
125
2
8
    if ($? == -1) {
126
0
0
        print "could not compare '$one' and '$two': $!\n";
127
0
0
        return 0;
128    }
129
2
6
    if ($? & 127) {
130
0
0
        printf "child died with signal %d, %s core dump\n",
131        ($? & 127),  ($? & 128) ? 'with' : 'without';
132
0
0
        return 0;
133    }
134
2
2
    return 0 if $? == 0;
135
2
7
    return 1;
136}
137
138
2
2
my $bash_script=q{
139=END_OF_PERL@
140# bash
141set -e
142if [ "$OUTPUT" = "$ERROR" ]; then
143    ("$@" 2>&1) > "$OUTPUT"
144else
145    "$@" > "$OUTPUT" 2> "$ERROR"
146fi
147exit
148};
149
150sub check_current_script {
151
2
3
    if ("$0" eq '-') {
152
0
0
        my ($bash_script) = @_;
153
0
0
        my $fh;
154
0
0
        ($fh, $0) = tempfile();
155
0
0
        $bash_script =~ s/^=.*\@$//m;
156
0
0
        print $fh $bash_script;
157
0
0
        close $fh;
158
0
0
        return;
159    }
160
2
2
    my $filename = tempfile_name();
161
2
2
    my $source = 'https://raw.githubusercontent.com/check-spelling/check-spelling/prerelease/apply.pl';
162
2
2
    download_with_curl($source, $filename);
163
2
34
    if ($? == 0) {
164
2
11
        if (compare_files($filename, $0)) {
165
2
11
            print "Current apply script differs from '$source' (locally downloaded to `$filename`). You may wish to upgrade.\n";
166        }
167    }
168}
169
170sub die_with_message {
171
0
0
    our $program;
172
0
0
    my ($gh_err_text) = @_;
173
0
0
    if ($gh_err_text =~ /error connecting to / && $gh_err_text =~ /check your internet connection/) {
174
0
0
        print "$program: Internet access may be limited. Check your connection (this often happens with lousy cable internet service providers where their CG-NAT or whatever strands the modem).\n\n$gh_err_text";
175
0
0
        exit 5;
176    }
177
0
0
    if ($gh_err_text =~ /proxyconnect tcp:.*connect: connection refused/) {
178
0
0
        print "$program: Proxy is not accepting connections.\n";
179
0
0
        for my $proxy (qw(http_proxy HTTP_PROXY https_proxy HTTPS_PROXY)) {
180
0
0
            if (defined $ENV{$proxy}) {
181
0
0
                print "  $proxy: '$ENV{$proxy}'\n";
182            }
183        }
184
0
0
        print "\n$gh_err_text";
185
0
0
        exit 6;
186    }
187
0
0
    if ($gh_err_text =~ /dial unix .*: connect: .*/) {
188
0
0
        print "$program: Unix http socket is not working.\n";
189
0
0
        my $gh_http_unix_socket = `gh config get http_unix_socket`;
190
0
0
        print "  http_unix_socket: $gh_http_unix_socket\n";
191
0
0
        print "\n$gh_err_text";
192
0
0
        exit 7;
193    }
194}
195
196sub gh_is_happy_internal {
197
0
0
    my ($output, $exit) = capture_merged_system(qw(gh api /installation/repositories));
198
0
0
    return ($exit, $output) if $exit == 0;
199
0
0
    ($output, $exit) = capture_merged_system(qw(gh api /user));
200
0
0
    return ($exit, $output);
201}
202
203sub gh_is_happy {
204
0
0
    my ($program) = @_;
205
0
0
    my ($gh_auth_status, $gh_status_lines) = gh_is_happy_internal();
206
0
0
    return 1 if $gh_auth_status == 0;
207
0
0
    die_with_message($gh_status_lines);
208
209
0
0
    my @problematic_env_variables;
210
0
0
    for my $variable (qw(GH_TOKEN GITHUB_TOKEN GITHUB_ACTIONS CI)) {
211
0
0
        if (defined $ENV{$variable}) {
212
0
0
            delete $ENV{$variable};
213
0
0
            push @problematic_env_variables, $variable;
214
0
0
            ($gh_auth_status, $gh_status_lines) = gh_is_happy_internal();
215
0
0
            if ($gh_auth_status == 0) {
216
0
0
                print STDERR "$0: gh program did not like these environment variables: ".join(', ', @problematic_env_variables)." -- consider unsetting them.\n";
217
0
0
                return 1;
218            }
219        }
220    }
221
222
0
0
    print $gh_status_lines;
223
0
0
    return 0;
224}
225
226sub tools_are_ready {
227
0
0
    my ($program) = @_;
228
0
0
    unless (gh_is_happy($program)) {
229
0
0
        $! = 1;
230
0
0
        my $or_gh_token = (defined $ENV{CI} && $ENV{CI}) ? ' or set the GH_TOKEN environment variable' : '';
231
0
0
        die "$program requires a happy gh, please try 'gh auth login'$or_gh_token\n";
232    }
233}
234
235sub maybe_unlink {
236
0
0
    unlink($_[0]) if $_[0];
237}
238
239sub run_pipe {
240
14
29
    my @args = @_;
241
14
23
    my ($out, undef, $exit) = capture_system(@args);
242
14
39
    return $out;
243}
244
245sub unzip_pipe {
246
12
40
    my ($artifact, $file) = @_;
247
12
13
    return run_pipe(
248        'unzip',
249        '-p', $artifact,
250        $file
251    );
252}
253
254sub retrieve_spell_check_this {
255
2
5
    my ($artifact, $config_ref) = @_;
256
2
2
    my $spell_check_this_config = unzip_pipe($artifact, 'spell_check_this.json');
257
2
9
    return unless $spell_check_this_config =~ /\{.*\}/s;
258
0
0
    my %config;
259
0
0
0
0
0
0
    eval { %config = %{decode_json $spell_check_this_config}; } || die "decode_json failed in retrieve_spell_check_this with '$spell_check_this_config'";
260
0
0
    my ($repo, $branch, $destination, $path) = ($config{url}, $config{branch}, $config{config}, $config{path});
261
0
0
    my $spell_check_this_dir = tempdir();
262
0
0
    my $exit;
263
0
0
    (undef, undef, $exit) = capture_system(
264            'git', 'clone',
265            '--depth', '1',
266            '--no-tags',
267            $repo,
268            '--branch', $branch,
269            $spell_check_this_dir
270        );
271
0
0
    if ($?) {
272
0
0
        die "git clone $repo#$branch failed";
273    }
274
275
0
0
    make_path($destination);
276
0
0
    system('cp', '-i', '-R', glob("$spell_check_this_dir/$path/*"), $destination);
277
0
0
    system('git', 'add', '-f', $destination);
278}
279
280sub case_biased {
281
29
27
    lc($a)."-".$a cmp lc($b)."-".$b;
282}
283
284sub add_to_excludes {
285
2
4
    my ($artifact, $config_ref) = @_;
286
2
2
3
9
    my %config = %{$config_ref};
287
2
2
    my $excludes = $config{"excludes_file"};
288
2
5
    my $should_exclude_patterns = unzip_pipe($artifact, 'should_exclude.patterns');
289
2
9
    unless ($should_exclude_patterns =~ /\w/) {
290
2
5
        $should_exclude_patterns = unzip_pipe($artifact, 'should_exclude.txt');
291
2
19
        return unless $should_exclude_patterns =~ /\w/;
292
0
0
        $should_exclude_patterns =~ s{^(.*)}{^\\Q$1\\E\$}gm;
293    }
294
0
0
    my $need_to_add_excludes;
295    my %excludes;
296
0
0
    if (-f $excludes) {
297
0
0
        open EXCLUDES, '<', $excludes;
298
0
0
        while (<EXCLUDES>) {
299
0
0
            chomp;
300
0
0
            next unless /./;
301
0
0
            $excludes{$_."\n"} = 1;
302        }
303
0
0
        close EXCLUDES;
304    } else {
305
0
0
        $need_to_add_excludes = 1;
306    }
307
0
0
    for $pattern (split /\n/, $should_exclude_patterns) {
308
0
0
        next unless $pattern =~ /./;
309
0
0
        $excludes{$pattern."\n"} = 1;
310    }
311
0
0
    open EXCLUDES, '>', $excludes;
312
0
0
    print EXCLUDES join "", sort case_biased keys %excludes;
313
0
0
    close EXCLUDES;
314
0
0
    system('git', 'add', '--', $excludes) if $need_to_add_excludes;
315}
316
317sub remove_stale {
318
2
16
    my ($artifact, $config_ref) = @_;
319
2
3
    my @stale = split /\s+/s, unzip_pipe($artifact, 'remove_words.txt');
320
2
21
    return unless @stale;
321
2
2
2
14
    my %config = %{$config_ref};
322
2
2
2
4
    my @expect_files = @{$config{"expect_files"}};
323    @expect_files = grep {
324
2
2
4
13
        print STDERR "Could not find $_\n" unless -f $_;
325
2
8
        -f $_;
326    } @expect_files;
327
2
3
    unless (@expect_files) {
328
0
0
        die "Could not find any of the processed expect files, are you on the wrong branch?";
329    }
330
331
2
3
    my $re = join "|", @stale;
332
2
4
    for my $file (@expect_files) {
333
2
18
        open INPUT, '<', $file;
334
2
2
        my @keep;
335
2
9
        while (<INPUT>) {
336
6
78
            next if /^(?:$re)(?:(?:\r|\n)*$|[# ].*)/;
337
4
7
            push @keep, $_;
338        }
339
2
11
        close INPUT;
340
341
2
82
        open OUTPUT, '>', $file;
342
2
8
        print OUTPUT join '', @keep;
343
2
82
        close OUTPUT;
344    };
345}
346
347sub add_expect {
348
2
2
    my ($artifact, $config_ref) = @_;
349
2
3
    my @add = split /\s+/s, (unzip_pipe($artifact, 'tokens.txt'));
350
2
8
    return unless @add;
351
2
2
3
14
    my %config = %{$config_ref};
352
2
3
    my $new_expect_file = $config{"new_expect_file"};
353
2
2
    my @words;
354
2
264
    make_path (dirname($new_expect_file));
355
2
8
    if (-s $new_expect_file) {
356
2
19
        open FILE, q{<}, $new_expect_file;
357
2
7
        local $/ = undef;
358
2
17
        @words = split /\s+/, <FILE>;
359
2
7
        close FILE;
360    }
361
2
2
    my %items;
362
2
6
    @items{@words} = @words x (1);
363
2
11
    @items{@add} = @add x (1);
364
2
38
    @words = sort case_biased keys %items;
365
2
64
    open FILE, q{>}, $new_expect_file;
366
2
4
    for my $word (@words) {
367
16
30
        print FILE "$word\n" if $word =~ /\S/;
368    };
369
2
70
    close FILE;
370
2
5176
    system("git", "add", $new_expect_file);
371}
372
373sub get_artifacts {
374
0
0
    my ($repo, $run, $suffix) = @_;
375
0
0
    our $program;
376
0
0
    my $artifact_dir = tempdir(CLEANUP => 1);
377
0
0
    my $gh_err_text;
378
0
0
    my $artifact_name = 'check-spelling-comment';
379
0
0
    if ($suffix) {
380
0
0
        $artifact_name .= "-$suffix";
381    }
382
0
0
    while (1) {
383
0
0
        ($gh_err_text, $ret) = capture_merged_system(
384            'gh', 'run', 'download',
385            '-D', $artifact_dir,
386            '-R', $repo,
387            $run,
388            '-n', $artifact_name
389        );
390
0
0
        return glob("$artifact_dir/artifact*.zip") unless ($ret >> 8);
391
392
0
0
        die_with_message($gh_err_text);
393
0
0
        if ($gh_err_text =~ /no valid artifacts found to download/) {
394
0
0
            my $expired_json = run_pipe(
395                'gh', 'api',
396                "/repos/$repo/actions/runs/$run/artifacts",
397                '-q',
398                '.artifacts.[]|select(.name=="'.$artifact_name.'")|.expired'
399            );
400
0
0
            if ($expired_json ne '') {
401
0
0
                chomp $expired_json;
402
0
0
                my $expired;
403
0
0
0
0
                eval { $expired = decode_json $expired_json } || die "decode_json failed in update_repository with '$expired_json'";
404
0
0
                if ($expired) {
405
0
0
                    print "$program: GitHub Run Artifact expired. You will need to trigger a new run.\n";
406
0
0
                    exit 1;
407                }
408            }
409
0
0
            print "$program: GitHub Run may not have completed. If so, please wait for it to finish and try again.\n";
410
0
0
            exit 2;
411        }
412
0
0
        if ($gh_err_text =~ /no artifact matches any of the names or patterns provided/) {
413
0
0
            $github_server_url = $ENV{GITHUB_SERVER_URL} || '';
414
0
0
            my $run_link;
415
0
0
            if ($github_server_url) {
416
0
0
                $run_link = "[$run]($github_server_url/$repo/actions/runs/$run)";
417            } else {
418
0
0
                $run_link = "$run";
419            }
420
0
0
            print "$program: The referenced repository ($repo) run ($run_link) does not have a corresponding artifact ($artifact_name). If it was deleted, that's unfortunate. Consider pushing a change to the branch to trigger a new run?\n";
421
0
0
            print "If you don't think anyone deleted the artifact, please file a bug to https://github.com/check-spelling/check-spelling/issues/new including as much information about how you triggered this error as possible.\n";
422
0
0
            exit 3;
423        }
424
0
0
        if ($gh_err_text =~ /HTTP 404: Not Found/) {
425
0
0
            print "$program: The referenced repository ($repo) may not exist, perhaps you do not have permission to see it. If the repository is hosted by GitHub Enterprise, check-spelling does not know how to integrate with it.\n";
426
0
0
            exit 8;
427        }
428
0
0
        unless ($gh_err_text =~ /HTTP 403: API rate limit exceeded for .*?./) {
429
0
0
            print "$program: Unknown error, please file a bug to https://github.com/check-spelling/check-spelling/issues/new\n";
430
0
0
            print $gh_err_text;
431
0
0
            exit 4;
432        }
433
0
0
        my $request_id = $1 if ($gh_err_text =~ /\brequest ID\s+(\S+)/);
434
0
0
        my $timestamp = $1 if ($gh_err_text =~ /\btimestamp\s+(.*? UTC)/);
435
0
0
        my $has_gh_token = defined $ENV{GH_TOKEN} || defined $ENV{GITHUB_TOKEN};
436
0
0
        my $meta_url = 'https://api.github.com/meta';
437
0
0
        while (1) {
438
0
0
            my @curl_args = qw(curl);
439
0
0
            unless ($has_gh_token) {
440
0
0
                my $gh_token = `gh auth token`;
441
0
0
                push @curl_args, '-u', "token:$gh_token" unless $?;
442            }
443
0
0
            push @curl_args, '-I', $meta_url;
444
0
0
            my ($curl_stdout, $curl_stderr, $curl_result);
445
0
0
            ($curl_stdout, $curl_stderr, $curl_result) = capture_system(@curl_args);
446
0
0
            my $delay = 1;
447
0
0
            if ($curl_stdout =~ m{^HTTP/\S+\s+200}) {
448
0
0
                if ($curl_stdout =~ m{^x-ratelimit-remaining:\s+(\d+)$}m) {
449
0
0
                    my $ratelimit_remaining = $1;
450
0
0
                    last if ($ratelimit_remaining > 10);
451
452
0
0
                    $delay = 5;
453
0
0
                    print STDERR "Sleeping for $delay seconds because $ratelimit_remaining is close to 0\n";
454                } else {
455
0
0
                    print STDERR "Couldn't find x-ratelimit-remaining, will sleep for $delay\n";
456                }
457            } elsif ($curl_stdout =~ m{^HTTP/\S+\s+403}) {
458
0
0
                if ($curl_stdout =~ /^retry-after:\s+(\d+)/m) {
459
0
0
                    $delay = $1;
460
0
0
                    print STDERR "Sleeping for $delay seconds (presumably due to API rate limit)\n";
461                } else {
462
0
0
                    print STDERR "Couldn't find retry-after, will sleep for $delay\n";
463                }
464            } else {
465
0
0
                my $response = $1 if $curl_stdout =~ m{^(HTTP/\S+)};
466
0
0
                print STDERR "Unexpected response ($response) from $meta_url; sleeping for $delay\n";
467            }
468
0
0
            sleep $delay;
469        }
470    }
471}
472
473sub update_repository {
474
2
2
    my ($artifact) = @_;
475
2
6
    die if $artifact =~ /'/;
476
2
2
    our $program;
477
2
8
    my $apply = unzip_pipe($artifact, 'apply.json');
478
2
14
    unless ($apply =~ /\{.*\}/s) {
479
0
0
        print STDERR "$program: Could not retrieve valid apply.json from artifact\n";
480
0
0
        $apply = '{
481            "expect_files": [".github/actions/spelling/expect.txt"],
482            "new_expect_file": ".github/actions/spelling/expect.txt",
483            "excludes_file": ".github/actions/spelling/excludes.txt",
484            "spelling_config": ".github/actions/spelling"
485        }';
486    }
487
2
3
    my $config_ref;
488
2
2
4
13
    eval { $config_ref = decode_json($apply); } ||
489        die "$program: decode_json failed in update_repository with '$apply'";
490
491
2
3335
    my $git_repo_root = run_pipe('git', 'rev-parse', '--show-toplevel');
492
2
3
    chomp $git_repo_root;
493
2
12
    die "$program: Could not find git repo root..." unless $git_repo_root =~ /\w/;
494
2
12
    chdir $git_repo_root;
495
496
2
7
    retrieve_spell_check_this($artifact, $config_ref);
497
2
11
    remove_stale($artifact, $config_ref);
498
2
9
    add_expect($artifact, $config_ref);
499
2
18
    add_to_excludes($artifact, $config_ref);
500
2
5045
    system('git', 'add', '-u', '--', $config_ref->{'spelling_config'});
501}
502
503sub main {
504
2
2
    our $program;
505
2
0
    my ($bash_script, $first, $run);
506
2
3
    ($program, $bash_script, $first, $run) = @_;
507
2
2
    my $syntax = "$program <RUN_URL | OWNER/REPO RUN | ARTIFACT.zip>";
508    # Stages
509    # - 1 check for tools basic
510
2
2
    check_basic_tools();
511    # - 2 check for current
512    # -> 1 download the latest version to a temp file
513    # -> 2. parse current and latest (stripping comments) and compare (whitespace insensitively)
514    # -> 3. offer to update if the latest version is different
515
2
8
    check_current_script($bash_script);
516    # - 4 parse arguments
517
2
2
    die $syntax unless defined $first;
518
2
3
    my $repo;
519    my @artifacts;
520
2
11
    if (-s $first) {
521
2
2
        my $artifact = $first;
522
2
2010
        open my $artifact_reader, '-|', 'unzip', '-l', $artifact;
523
2
13
        my ($has_artifact, $only_file) = (0, 0);
524
2
1562
        while (my $line = <$artifact_reader>) {
525
16
10
            chomp $line;
526
16
15
            if ($line =~ /\s+artifact\.zip$/) {
527
0
0
                $has_artifact = 1;
528
0
0
                next;
529            }
530
16
19
            if ($line =~ /\s+1 file$/) {
531
0
0
                $only_file = 1;
532
0
0
                next;
533            }
534
16
380
            $only_file = 0 if $only_file;
535        }
536
2
20
        close $artifact_reader;
537
2
9
        if ($has_artifact && $only_file) {
538
0
0
            my $artifact_dir = tempdir(CLEANUP => 1);
539
0
0
            my ($fh, $gh_err) = tempfile();
540
0
0
            close $fh;
541
0
0
            system('unzip', '-q', '-d', $artifact_dir, $artifact, 'artifact.zip');
542
0
0
            @artifacts = ("$artifact_dir/artifact.zip");
543        } else {
544
2
16
            @artifacts = ($artifact);
545        }
546    } else {
547
0
0
        my $suffix;
548
0
0
        if ($first =~ m{^\s*https://.*/([^/]+/[^/]+)/actions/runs/(\d+)(?:/attempts/\d+|)(?:#(\S+)|)\s*$}) {
549
0
0
            ($repo, $run, $suffix) = ($1, $2, $3);
550        } else {
551
0
0
            $repo = $first;
552        }
553
0
0
        die $syntax unless defined $repo && defined $run;
554        # - 3 check for tool readiness (is `gh` working)
555
0
0
        tools_are_ready($program);
556
0
0
        @artifacts = get_artifacts($repo, $run, $suffix);
557    }
558
559    # - 5 do work
560
2
5
    for my $artifact (@artifacts) {
561
2
8
        update_repository($artifact);
562    }
563}
564
565
2
4
main($0 ne '-' ? $0 : 'apply.pl', $bash_script, @ARGV);