Tag: Perl

  • Porting from Perl to Go: Simplifying for Platform Engineering

    Porting from Perl to Go: Simplifying for Platform Engineering

    Rewriting a script for the Homebrew pack­age man­ag­ertaught me how the Gopro­gram­ming lan­guage’s design choic­es align with platform-​ready tools.

    The problem with brew upgrade

    By default, the brew upgradecom­mand updates every for­mu­la(ter­mi­nal util­i­ty or library). It also updates every cask(GUI appli­ca­tion) it man­ages. All are upgrad­ed to the lat­est ver­sion — major, minor, and patch. That’s con­ve­nient when you want the newest fea­tures, but dis­rup­tive when you only want qui­et patch-​level fixes.

    Last weekI solved this in Perlwith brew-patch-upgrade.pl, a script that parsed brew upgrade​’s JSON out­put, com­pared seman­tic ver­sions, and upgrad­ed only when the patch num­ber changed. It worked, but it also remind­ed me how much Perl leans on implic­it struc­tures and run­time flexibility.

    This week I port­ed the script to Go, the lin­gua fran­caof DevOps. The goal was­n’t fea­ture par­i­ty — it was to see how Go’s design choic­es map onto plat­form engi­neer­ing concerns.

    Why port to Go?

    • Portfolio prac­tice: I’m build­ing a body of work that demon­strates plat­form engi­neer­ingskills.
    • Operational focus: Go is wide­ly used for tool­ing in infra­struc­ture and cloud environments.
    • Learning by con­trast: Rewriting a work­ing Perl script in Go forces me to con­front dif­fer­ences in error han­dling, type safe­ty, and distribution.

    The journey

    Error handling philosophy

    Perl gave me try/​catch(exper­i­men­talin the Perl v5.34.1 that ships with macOS, but since accept­ed into the lan­guage in v5.40). Go, famous­ly, does not. Instead, every func­tion returns an error explicitly.

    Perl:

    use v5.34;
    use warnings;
    use experimental qw(try);
    use Carp;
    use autodie;
    
    ...
    
    try {
      system 'brew', 'upgrade', $name;
      $result = 'upgraded';
    }
    catch ($e) {
      $result = 'failed';
      carp $e;
    }

    Go:

    package main
    
    import (
      "os/exec"
      "log"
    )
    
    ...
    
    cmd := exec.Command("brew", "upgrade", name)
    if output, err := cmd.CombinedOutput(); err != nil {
      log.Printf("failed to upgrade %s: %v\n%s",
        name,
        err,
        output)
    }

    The Go ver­sion is nois­i­er, but it forces explic­it deci­sions. That’s a fea­ture in pro­duc­tion tool­ing: no silent failures.

    Dependency management

    • Perl: cpanfile+ CPAN mod­ules. Distribution means ​“install Perl(if it’s not already), install mod­ules, run script.” Tools like cartonand the cpanor cpanmcom­mands help auto­mate this. Additionally, one can use fur­ther tool­ing like fatpackand ppto build more self-​contained pack­ages. But those are nei­ther com­mon nor (except for cpan) dis­trib­uted with Perl.
    • Go: go.mod+ go build. Distribution is a sin­gle (platform-​specific) binary.

    For oper­a­tional tools, that’s a mas­sive sim­pli­fi­ca­tion. No run­time inter­preter, no depen­den­cy dance.

    Type safety

    Perl let me parse JSON into hashrefsand trust the keys exist. Go required a struct:

    type Formula struct {
      Name              string   `json:"name"`
      CurrentVersion    string   `json:"current_version"`
      InstalledVersions []string `json:"installed_versions"`
    }

    The com­pil­er enforces assump­tions that Perl left implic­it. That fric­tion is valu­able — it sur­faces errors early.

    Binary distribution

    This is where Go shines. Instead of telling col­leagues ​“install Perlv5.34 and CPAN mod­ules,” I can hand them a bina­ry. No need to wor­ry about script­ing run­time envi­ron­ments — just grab the right file for your system.

    Available on the release page. Download, run, done.

    Semantic versioning logic

    In Perl, I man­u­al­ly com­pared arrays of ver­sion num­bers. In Go, I import­ed golang.org/x/mod/semver:

    import (
      golang.org/x/mod/semver
    )
    
    ...
    
    if semver.MajorMinor(toSemver(formula.InstalledVersions[0])) !=
      semver.MajorMinor(toSemver(formula.CurrentVersion)) {
      log.Printf("%s is not a patch upgrade", formula.Name)
      results.skipped++
      continue
    }

    Cleaner, more leg­i­ble, and less error-​prone. The library encodes the con­ven­tion, so I don’t have to.

    Deliberate simplification

    I did­n’t port every fea­ture. Logging adapters, sig­nalhan­dlers, and edge-​case diag­nos­tics remained in Perl. The Go ver­sion focus­es on the core log­ic: parse JSON, com­pare ver­sions, run upgrades. That restraint was inten­tion­al — I want­ed to learn Go’s idioms, not repli­cate every Perl flourish.

    Platform engineering insights

    Three lessons stood out:

    1. Binary dis­tri­b­u­tion mat­ters.Operational tools should be instal­lable with a sin­gle copy step. Go makes that trivial.
    2. Semantic ver­sion­ingis an oper­a­tional prac­tice.It’s not just a con­ven­tion for library authors — it’s a con­tract that tool­ing can enforce.
    3. Go’s design aligns with plat­form needs.Explicit errors, type safe­ty, and sta­t­ic bina­ries all reduce sur­pris­es in production.

    Bringing it home

    This isn’t a ​“Perl vs. Go” sto­ry. It’s a sto­ry about delib­er­ate sim­pli­fi­ca­tion, tak­ing a work­ing Perl script and recast­ing it in Go. The aim is to see how the lan­guage’s choic­es shape a solu­tion to the same problem.

    The result is homebrew-semver-guard v0.1.0, a small but stur­dy tool. It’s not feature-​finished, but it’s production-​ready in the ways that matter.

    Next up: I’m con­sid­er­ing more Go tools, maybe even Kubernetes for ser­vices on my home serv­er. This port was prac­tice, an arti­fact demon­strat­ing plat­form engi­neer­ing in action.


    Links

  • Patch-​Perfect: Smarter Homebrew Upgrades on macOS

    Patch-​Perfect: Smarter Homebrew Upgrades on macOS

    This is a sto­ry about patches.

    At first, I just want­ed Homebrewto behave a lit­tle more polite­ly. Formulaeshould upgrade only on patch changes, with­out being dragged through minor and major bumps. That itch became a small(ish) Perl script, brew-patch-upgrade.pl.

    Along the way, I dis­cov­ered anoth­er patch was need­ed. My own log­ging adapter, Log::Any::Adapter::MacOS::OSLog, was­n’t build­ing and installing its bun­dle cor­rect­ly. Before the script can shine in the Mac Console app, I had to fix the adapter itself.

    What fol­lows is how those two threads came togeth­er. One is a tool that keeps Homebrew upgrades patch-​perfect. The oth­er is a log­ging adapter that final­ly behaves as a first-​class Perl module.

    What is Homebrew?

    Not to be con­fused with perlbrew, Homebrewis a free and open-​source pack­age man­ag­er for macOS (and Linux). It makes it easy to install and update soft­ware from the com­mand line. You don’t have to hunt down installers on web­sites. Instead, you can type com­mands like brew install wget. Homebrew will then fetch, build, and link the tool into place.

    Homebrew ≠ Perlbrew

    Everything is orga­nized under /opt/homebrew(on Apple Silicon-​based Macs) or /usr/local(on Intel). Homebrew can even man­age both command‑line util­i­ties (“for­mu­lae”) and desk­top apps (“casks”).

    In short: it’s the miss­ing pack­age man­ag­er Apple nev­er shipped, and it’s become an essen­tial part of many devel­op­ers’ workflows.

    Homebrew makes it easy to stay up to date — some­times tooeasy. By default, brew upgradejumps to the lat­est ver­sion of every­thing, even across minor and major releases.

    That’s fine when you want the newest fea­tures. Yet, it can be dis­rup­tive if all you real­ly need are the qui­et, patch-​level fixes.

    Scripted patch-​only upgrades

    My brew‑patch‑upgrade.pltakes a nar­row­er view: it pars­es brew outdatedcom­mand, com­pares seman­tic ver­sions, and upgrades only when the patch num­ber changes.

    That means:

    • 3.2.1→ 3.2.4will be upgraded.
    • 3.2.1→ 3.3.0will be skipped.
    • 3.2.1→ 4.0.0will be skipped.

    Normally, the scrip­t’s out­put is com­pa­ra­ble to brew upgrade, stream­ing the famil­iar Homebrew out­put to your ter­mi­nal for any patch-​level updates while not­ing any skipped versions:

    % brew-patch-upgrade.pl
    Skipping harfbuzz, needs manual review before upgrade at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 178.
    	main::process_formula(HASH(0x7c075dc90)) called at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 121
    Skipping woodpecker-cli, needs manual review before upgrade at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 178.
    	main::process_formula(HASH(0x7c0c1a180)) called at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 121
    ==> Upgrading 1 outdated package:
    zstd 1.5.6 -> 1.5.7
    ==> Fetching downloads for: zstd
    ==> Fetching zstd
    ==> Downloading https://ghcr.io/v2/homebrew/core/zstd/blobs/sha256:ddb0c145060bc2366ce5d58d95aa205bb15cb4c66948f20bb85e23fdb5eba7e9
    Already downloaded: /Users/mjg/Library/Caches/Homebrew/downloads/eb865576547e163ef6908f1e5762f4dc4d7fb548940f7b896ae12c0d5b202362--zstd--1.5.7.arm64_tahoe.bottle.1.tar.gz
    ==> Upgrading zstd
      1.5.6 -> 1.5.7
    ==> Pouring zstd--1.5.7.arm64_tahoe.bottle.1.tar.gz
    🍺  /opt/homebrew/Cellar/zstd/1.5.7: 32 files, 2.2MB
    ==> Running `brew cleanup zstd`...
    Disable this behaviour by setting `HOMEBREW_NO_INSTALL_CLEANUP=1`.
    Hide these hints with `HOMEBREW_NO_ENV_HINTS=1` (see `man brew`).
    Removing: /opt/homebrew/Cellar/zstd/1.5.6... (32 files, 2.1MB)
    Removing: /Users/mjg/Library/Caches/Homebrew/zstd_bottle_manifest--1.5.6... (11.9KB)
    Removing: /Users/mjg/Library/Caches/Homebrew/zstd--1.5.6... (753.9KB)
    ==> No outdated dependents to upgrade!

    But since I intend­ed this to run unat­tend­ed on a sched­ule, you can also tell it to log to a dif­fer­ent des­ti­na­tion using the envi­ron­ment vari­able LOG_ANY_DEFAULT_ADAPTER:

    % LOG_ANY_DEFAULT_ADAPTER=Stderr brew-patch-upgrade.pl
    using log adapter Log::Any::Adapter::Stderr
    outdated formulae: {casks => [],formulae => [{current_version => "12.0.0",installed_versions => ["11.5.1"],name => "harfbuzz",pinned => bless( do{\(my $o = 0)}, 'JSON::PP::Boolean' ),pinned_version => undef},{current_version => "3.10.0",installed_versions => ["3.9.0"],name => "woodpecker-cli",pinned => $VAR1->{formulae}[0]{pinned},pinned_version => undef},{current_version => "1.5.7",installed_versions => ["1.5.6","1.5.7"],name => "zstd",pinned => $VAR1->{formulae}[0]{pinned},pinned_version => undef}]}
    Skipping harfbuzz, needs manual review before upgrade at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 178.
    	main::process_formula(HASH(0xa1a01f510)) called at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 121
    Skipping woodpecker-cli, needs manual review before upgrade at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 178.
    	main::process_formula(HASH(0xa1acbcf30)) called at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 121
    Starting brew upgrade for zstd...
    ==> Upgrading 1 outdated package:
    zstd 1.5.6 -> 1.5.7
    Finished brew upgrade for zstd
    Summary: upgraded 1, skipped 2, failed 0

    That’s use­ful for debug­ging. But, the real pay­off comes when you send logs to a more sophis­ti­cat­ed adapter. A good exam­ple is my Log::Any::Adapter::MacOS::OSLogpack­age. It tags dif­fer­ent lev­els of log mes­sages in macOS’ uni­fied log­ging sys­tem. These mes­sages can then be fil­tered and searched in the Console util­i­ty app.

    The com­mand is like the Stderrexam­ple above:

    % LOG_ANY_DEFAULT_ADAPTER=MacOS::OSLog brew-patch-upgrade.pl

    And pro­duces results that can be viewed in Console:

    Screenshot of macOS' Console app showing messages from the com.phoenixtrap.brew-patch-upgrade subsystem

    Or using the macOS log showcommand:

    Screenshot of macOS' iTerm2 app showing messages from the com.phoenixtrap.brew-patch-upgrade subsystem of the unified log

    At the end, the script logs a sum­ma­ry of upgrad­ed, skipped, and failed for­mu­lae. On fail­ure, it exits non-​zero to make it easy to use in automation.

    Highlights from the script source code

    For those curi­ous about the inter­nals, here are a few high­lights from the source.

    Skipping major and minor version bumps

    use version;
    ...
    sub process_formula ($formula_ref) {
        my $name = $formula_ref->{name};
        my ( $current, $installed ) = map { defined and qv($_) } (
            $formula_ref->{current_version},
            $formula_ref->{installed_versions}->[0] );
        unless ( $current and $installed ) {
            $logger->debug(
                'no current and/or installed version detected for',
                $name );
            return 'skipped';
        }
        my $result = 'failed';    # default
        my @current   = $current->{version}->@*;
        my @installed = $installed->{version}->@*;
        #<<<
        unless (@current   >= 3
            and @installed >= 3
            and $installed[0] == $current[0]
            and $installed[1] == $current[1]
            and $installed[2] <  $current[2] )
        #>>>
        {
            carp "Skipping $name, needs manual review before upgrade";
            return 'skipped';
        }
        ...

    At the start, process_formulachecks whether a for­mu­la from brew outdatedhas a valid seman­tic ver­sion. Only then does it com­pare patch numbers.

    To that end, it uses the core Perl versionmod­ule’s qvfunc­tion. This pars­es the cur­rent and lat­est installed ver­sion, avoid­ing a com­pli­cat­ed reg­u­lar expres­sion. The result­ing ver­sion objects can be treat­ed as hash ref­er­ences with a versionkey. This key is itself a ref­er­ence to an array con­tain­ing the major, minor, and patch ver­sion num­bers. If the major or minor ver­sion num­bers dif­fer between installed and cur­rent, the script calls for a man­u­al review. Then it returns a ​“skipped” status.

    Setting up the loggers

    use Log::Any qw($logger);
    use Log::Any::Adapter;
    ...
    use constant {
        MAC_LOG_ADAPTER_CLASS => 'Log::Any::Adapter::MacOS::OSLog',
        MAC_LOG_SUBSYSTEM     => 'com.phoenixtrap.brew-patch-upgrade',
    };
    my %brew_log;
    if ( $ENV{LOG_ANY_DEFAULT_ADAPTER} ) {
        my $adapter = Log::Any::Adapter->get(__PACKAGE__);
        Log::Any::Adapter->set( q(+) . ref $adapter,
            subsystem => MAC_LOG_SUBSYSTEM )
            if $adapter->isa(MAC_LOG_ADAPTER_CLASS);
        $logger->debug( 'using log adapter', ref $adapter );
        $SIG{__WARN__} = sub ($message) { $logger->alert($message) };
        $SIG{__DIE__}  = sub ($message) { $logger->fatal($message) };
        foreach my $stdio (qw(stdout stderr)) {
            Log::Any::Adapter->set(
                { category => "brew.$stdio" },
                q(+) . ref $adapter,
                (   subsystem   => MAC_LOG_SUBSYSTEM,
                    os_category => "brew-$stdio",
                )x!!$adapter->isa(MAC_LOG_ADAPTER_CLASS),
            );
            $brew_log{$stdio}
                = Log::Any->get_logger( category => "brew.$stdio" );
        }
    }

    If LOG_ANY_DEFAULT_ADAPTERis set, the script cre­ates three log­gers: one for the pro­gram itself, one for brew.stdout, and one for brew.stderr. All three have log­ic to check if the default log­ger class is Log::Any::Adapter::MacOS::OSLog. If it is, an appro­pri­ate OS-​level log­ging cat­e­go­ry is set. A unique sub­sys­tem name for the script, ​“com.phoenixtrap.brew-patch-upgrade”, is also assigned.

    Unfortunately, while work­ing on this macOS-​specific log­ging code, I made a dis­cov­ery. I real­ized that I need­ed to fix my log adapter class with a patch of its own.

    OSLog revisited

    I was proud of my ear­li­er workon the MacOS::OSLoglog adapter — a clever wrap­per around macOS’ log­ging func­tions using Perl for­eign func­tion inter­face (FFI). I even took a vic­to­ry lap, con­vert­ing the CPAN dis­tri­b­u­tion to use Dist::Zilla for plug­gable installer cre­ation and documentation.

    But out­side the pack­aged maclogscript, the mod­ule did­n’t work. Oops.

    Once I tried using the MacOS::OSLogadapter in brew-patch-upgrade.pl, the cracks became obvi­ous. The bun­dle was­n’t built or installed cor­rect­ly. My _find_my_bundlehackwas brit­tle — not a foun­da­tion to build on.

    The solu­tion was to stop fak­ing it and let the FFI::Platypustool­chain do its job. Using Dist::Zilla::Plugin::MakeMaker::Awesome in my dist.inifile, I rewrote the build process to use FFI::Build::MMin the gen­er­at­ed Makefile.PLinstaller. This meant that the bun­dle would com­pile and install in the right place instead of rely­ing on my _find_my_bundlehack:

    [MakeMaker::Awesome]
    delimiter = |
    ...
    header = |use FFI::Build::MM;
    header = |my $fbmm = FFI::Build::MM->new();
    header = |my %fbmm_args = $fbmm->mm_args(
    header = |  DISTNAME     => 'Log-Any-Adapter-MacOS-OSLog',
    header = |  NAME         => 'Log::Any::Adapter::MacOS::OSLog',
    header = |  VERSION_FROM => 'lib/Log/Any/Adapter/MacOS/OSLog.pm',
    header = |);
    header = |$fbmm_args{CCFLAGS} .= ' -mmacosx-version-min=10.12';
    footer = |sub MY::postamble { $fbmm->mm_postamble }
    WriteMakefile_arg = %fbmm_args

    The loca­tion of the C source code as well as com­pi­la­tion flags moved to a sep­a­rate ffi/OSLog.fbxfile, which FFI::Build::MMwould infer the name of based on the argu­ments passed to its mm_argsmethod.

    {
      source => [ 'ffi/OSLog.c' ],
      cflags => [ '-mmacosx-version-min=10.12' ],
      libs   => [ '-framework', 'OSLog' ],
    }

    I also updat­ed the C source codewith an #include <ffi_platypus_bundle.h>line, giv­ing the result­ing code bun­dle a prop­er entry point that FFI::Platypuscould recognize.

    Finally, I set a default sub­sys­tem name (“com.example.perl”). Now users can make it their default Log::Anyadapter and start log­ging immediately.

    The fix is in

    With those fix­es, the adapter now works as a native-​grade com­po­nent. No hacks. No guess­ing. Just clean, struc­tured logs flow­ing into the uni­fied log­ging system.

    Each run of brew-patch-upgrade.plnow shows a clear sub­sys­tem, with cat­e­gories for STDOUT, STDERR, and the script itself. This makes it easy to fil­ter and review. And because the script exits with a sum­ma­ry line and prop­er exit code, it slots neat­ly into automa­tion as well.

    In oth­er words, the log­ging side of brew-patch-upgrade.plis now as patch-​perfect as the upgrade log­ic it supports.

    Looking back, I real­ized that this project was­n’t just about tam­ing Homebrew’s upgrades or fix­ing a stub­born Perl mod­ule. It was about cre­at­ing a stronger con­nec­tion between the tools I rely on and their expect­ed behav­ior. I want them to be pre­dictable, leg­i­ble, and resilient. Both pack­ages work togeth­er, keep­ing my upgrades qui­et and inten­tion­al while sur­fac­ing them in the Console app.

    Both the scriptand log adapterare on Codeberg, with the adapter also on CPAN. If you’d like to try them out, file issues, or sug­gest improve­ments, the repos are waiting.

  • Scraping the Dragon with Perl and Mojolicious

    Scraping the Dragon with Perl and Mojolicious

    Every extend­ed Labor Day week­end, 80,000 fans of pop cul­ture descend on Atlanta for Dragon Con. It’s a sprawl­ing choose-​your-​own adven­ture of a con­ven­tion with 38 pro­gram­ming tracks and over 5,000 hours of events. It spans five down­town host hotels, and there is no way to see it all.

    Sadly, this year’s con is almost over. Still, I thought I’d share a lit­tle script I wrote to help me make sense of it all.

    The offi­cial mobile appis fine for search­ing and book­mark­ing events, speak­ers, and exhibitors. Nonetheless, it’s not suit­able for scan­ning the whole land­scape at once. I want­ed a sin­gle, scrol­lable view of every event, before I even packed my cosplay.

    Even in the app’s tablet ver­sion, the Dragon Con Events area is a scroll-fest.

    The web ver­sion of the appgave me exact­ly what I need­ed: pre­dictable per-​day URLs and seman­ti­cal­ly marked-​up HTML. That meant I can skip the API hunt, skip the man­u­al scrolling, and go straight to scraping.

    Inspecting the HTML reveals per-​day event URLs and per-​event <div>blocks.

    From Chaos to Clarity in 40 lines

    We’re about to turn a messy, multi-​day, multi-​hotel sched­ule into one clean, scroll-​once list. This is the forty-​five-​line Perl map that gets us there, aid­ed by the Mojoliciousweb toolkit.

    Laying the Groundwork: Tools for the Job

    #!/usr/bin/env perl
    
    use v5.40;
    
    use Carp;
    use English;
    use Mojo::UserAgent;
    use Mojo::URL;
    use Mojo::DOM;
    use Mojo::Collection q(c);
    use Time::Piece;
    use HTML::HTML5::Entities;
    use Memoize;
    
    binmode STDOUT, ':encoding(UTF-8)'
      or croak "Couldn't encode STDOUT: $OS_ERROR";
    
    my $ua   = Mojo::UserAgent->new();
    my $site = Mojo::URL->new('https://app.core-apps.com');
    my $path = '/dragoncon25/events/view_by_day';

    What’s hap­pen­ing:Load the mod­ules that will do the heavy lifting–HTTP fetch­es, DOM pars­ing, date han­dling, Unicode cleanup. Lock STDOUT to UTF‑8 so char­ac­ters like curly quotes and em-​dashes don’t break the out­put. Point the script at the base sched­ule URL.

    Remembering the Days Without Re-Parsing

    my $date_from_dom = memoize( sub ($dom) {
      return content_at( $dom, 'div.section_header[class~="alt"]' );
    } );

    What’s hap­pen­ing:Create a mem­o­ized helper that plucks the date from a day’s HTML and caches it. That way, if we need it again, we skip the DOM re-​parse and keep the pipeline fast.

    content_atis a helper func­tion I define later.

    Starting Where the App Starts

    my $today_dom = Mojo::DOM->new( $ua->get("$site$path")->result->text );

    What’s hap­pen­ing:Fetch the ​“today” view–the same default the app shows. This is so we have a known start­ing point for build­ing the full timeline.

    Collecting the Whole Timeline

    my $day_doms = c(
      $today_dom,
      $today_dom->find(qq(div.filter_box-days > a[href^="$path?day="]))
        ->map( \&dom_from_anchor )
        ->to_array->@*,
    )->sort( sub { day_epoch($a) <=> day_epoch($b) } );

    What’s hap­pen­ing:Grab every day link from the fil­ter bar, fetch each day’s HTML, and sort them chrono­log­i­cal­ly. Now we’ve got the entire con’s sched­ule in mem­o­ry, ready to process.

    dom_from_anchorand day_epochare two more helper func­tions explained fur­ther down.

    Turning HTML into a Human-​Readable Schedule

    $day_doms->each( sub {    # process each day's events
      my $date = $date_from_dom->($_);
    
      $_->find('a.bookmark[data-type="events"] + a.object_link')
        ->each( sub {         # output start time + title
    
          my $time    = content_at( $_, 'div.line[class~="two"]' );
          my $title   = content_at( $_, 'div.line[class~="one"]' );
          my ($start) = split /\s*\p{Dash_Punctuation}/, $time;
    
          say "$date $start: ", decode_entities($title);
        } );
    } );

    What’s hap­pen­ing:For each day, find every event link and pull out the start time and title. Split the time clean­ly on any dash and decode HTML enti­ties so the out­put reads like a real schedule.

    The Little Routines That Make It All Work

    sub dom_from_anchor ($dom) {    # fetch DOM for a day link
      return Mojo::DOM->new(
        $ua->get( Mojo::URL->new( $dom->attr('href') )->to_abs($site) )
          ->result->text );
    }
    
    sub day_epoch ($dom) {    # parse date into epoch
      return Time::Piece->strptime( $date_from_dom->($dom), '%A, %b %e' )
        ->epoch;
    }
    
    # extract and trim text from selector
    sub content_at ( $dom, @args ) { return trim $dom->at(@args)->content }

    What’s hap­pen­ing:

    1. dom_from_anchor: fetch and pars­es a linked days’ HTML.
    2. day_epoch: turn a date string into a sort-​able epoch.
    3. content_at: extract and trim text from a DOM frag­ment, giv­en a CSS selector.

    These helpers keep the main flow read­able and re-usable.

    The Schedule, Unlocked

    Run the script and you get a clean, UTF-​8-​safe list of every event, in chrono­log­i­cal order, across all days. No swip­ing around, no tap­ping, no ​“what did I miss?” anx­i­ety. (Ha, who am I kid­ding? There’s too much going on at Dragon Con to notend up miss­ing something.)

    An exam­ple run of the script in my ter­mi­nal. Each line is ​“Day, Date Time: Event Title”, sort­ed chrono­log­i­cal­ly across the whole con.

    And here’s just a small slice of the 2,500+ lines it produces:

    Sunday, Aug 31 11:30 AM: Unmasking Sherlock: Beyond the Many Faces
    Sunday, Aug 31 11:30 AM: Weaponization of the FCC and Other Agencies to Chill Speech
    Sunday, Aug 31 11:30 AM: Where Physics Gets Weird
    . . .
    Sunday, Aug 31 11:50 AM: Photo Session: Amelia Tyler
    Sunday, Aug 31 11:50 AM: Photo Session: Cissy Jones
    Sunday, Aug 31 11:50 AM: Photo Session: Emma Gregory
    . . .
    Sunday, Aug 31 12:00 PM: Dragon Con Mashups
    Sunday, Aug 31 12:00 PM: James J. Butcher and R.R. Virdi signing at The Missing Volume booth# 1300
    Sunday, Aug 31 12:00 PM: JoeDan Worley and Eric Dontigney signing at the Shadow Alley Press Booth# 2
    . . .
    Sunday, Aug 31 12:00 PM: Photo Session: Robert Duncan McNeill
    Sunday, Aug 31 12:00 PM: Photo Session: Robert Picardo
    Sunday, Aug 31 12:00 PM: Photo Session: Tamara Taylor

    Key Techniques

    Here’s the fun part–the tech­niques that make this tidy, scroll-​once list possible.

    CSS selectors for precision

    I used a.bookmark[data-type="events" + a.object_link]to grab only the event title links, and div.line[class~="two"/​div.line[class~="one"]for time and title, respec­tive­ly. This avoids scrap­ing unre­lat­ed elements.

    Memoization for efficiency

    memoizecaches the date string for each day’s DOM so I did­n’t end up re-​parsing the HTML frag­ment mul­ti­ple times.

    Unicode-​safe splitting

    \p{Dash_Punctuation}match­es any dash type (em, en, hyphen-​minus, etc.), so I could split times reli­ably with­out wor­ry­ing about which dash the site uses.

    Functional chaining

    Mojo::Collection​’s map, sort, and eachmeth­ods let me express the scrape→transform→output pipeline in a lin­ear, read­able way.

    Entity decoding at output

    HTML::HTML5::Entities’ decode_entitiesis applied right before print­ing, so HTML enti­ties like &amp;or &quot;are human-​readable in the final output.

    A Pattern You Can Take Anywhere

    The same approach that tamed Dragon Con’s chaos works any­where you’ve got:

    • Predictable URLs–so you can iter­ate with­out guesswork
    • Consistent HTML structure–so your selec­tors stay stable
    • A need to see every­thingat once–so you can make deci­sions with­out pag­ing or filtering

    From fan con­ven­tions to con­fer­ence sched­ules, from local sports fix­tures to film fes­ti­val line‑ups–the same pat­tern applies. Sometimes the right tool isn’t a sprawl­ing frame­work or heavy­weight API client. It’s a forty‑odd‑line Perl script that does one thing with ruth­less clarity.

    Because once you’ve tamed a sched­ule like this, the only lines you’ll stand in are the ones that feel like part of the show.

  • Even lighter Perl modulinos with Util::H2O::More

    Even lighter Perl modulinos with Util::H2O::More

    A few weeks ago, I wroteabout how to use the mod­uli­no pat­tern in Perlto cre­ate unit-​testable com­mand line tools. Fellow Houston Perl MongerBrett Estrade point­ed me to a dif­fer­ent approach on the Perl Applications & Algorithms Discord. This approach trims boil­er­plate while keep­ing scripts testable.

    Brett’s Utils::H2O::Moremod­ule amends the light­weight class builder Utils::H2O. It adds many extra meth­ods, includ­ing com­mand line argu­ment pro­cess­ing via the Perl-​packaged Getopt::Longmod­ule. It also promis­es to build its acces­sors with less cer­e­mo­ny and code than Moo.

    So let’s dive in!

    A simple script

    A script can use Util::H2O::More’s Getopt2h2ofunc­tion to process com­mand line options. It returns an object with acces­sors for each parameter.

    Here’s a sim­ple exam­ple, mod­eled after my ear­li­er mod­uli­no exer­cise:

    #!/usr/bin/env perl
    
    use v5.38;
    use Util::H2O::More v0.4.2 qw(Getopt2h2o);
    
    # name is a string, water is an array of strings
    my $o = Getopt2h2o \@ARGV, {}, qw(
        name=s
        water=s@
    );
    die "Missing --name\n" unless $o->name;
    
    printf "Good %s, %s!\n", time_of_day(), $o->name;
    
    if ( defined $o->water and $o->water->@* ) {
        say 'What kind of water would you like?';
        say "- $_" for $o->water->@*;
    }
    
    sub time_of_day {
        my %hours = (
             5 => 'morning',
            12 => 'afternoon',
            17 => 'evening',
            21 => 'night',
        );
    
        for ( sort { $b <=> $a } keys %hours ) {
            return $hours{$_} if (localtime)[2] >= $_;
        }
        return 'night';
    }

    This is short andread­able. I like how Getopt::Long’s quirky para­me­ter pars­ing syn­tax is repur­posed to cre­ate acces­sors, even for multi-​valued options.

    You can call this script like so:

    ./h2options.pl --name Aquarius --water hot --water cold

    We had to check that --namewas set, as Util::H2O::More does­n’t sup­port using an =mod­i­fi­er to spec­i­fy required options. This is some­thing Getopt::Long allows.

    And as Util::H2O’s doc­u­men­ta­tion sug­gests: ​“You should prob­a­bly switchto some­thing like Mooinstead [for advanced features].”

    But enough about limitations–what if you want­ed to usethis as a Perl mod­ule for testing?

    Testing the waters

    One of the strengths of a mod­uli­no is the abil­i­ty to unit testits log­ic with­out invok­ing it from the shell. A typ­i­cal test script looks like this:

    #!/usr/bin/env perl
    
    use v5.38;
    use Test2::V0;
    use modulinh2o;
    
    plan(6);
    
    can_ok(
        'modulinh2o',
        [ 'time_of_day', 'name', 'water' ],
        'class has methods',
    );
    my $water = modulinh2o->new( name => 'Aquarius' );
    isa_ok( $water, ['modulinh2o'],
            'object is expected class' );
    can_ok(
        $water,
        [ 'time_of_day', 'name', 'water' ],
        'object has methods',
    );
    
    is( $water->name,        'Aquarius', 'name set' );
    is( $water->name('Bob'), 'Bob',      'name change' );
    is( $water->time_of_day,
        in_set( qw(
            morning
            afternoon
            evening
            night
        ) ),
        'time of day function',
    );

    It isn’t dif­fi­cult to adapt a mod­uli­no from our ear­li­er sim­ple script:

    #!/usr/bin/env perl
    
    use v5.38;
    
    package modulinh2o;
    
    use Getopt::Long qw();
    use Util::H2O::More v0.4.2 qw(h2o opt2h2o);
    
    my @opt_spec = qw(
        name=s
        water=s@
    );
    my $o = h2o -classify => __PACKAGE__, {},
            opt2h2o(@opt_spec);
    
    sub time_of_day {
        my %hours = (
             5 => 'morning',
            12 => 'afternoon',
            17 => 'evening',
            21 => 'night',
        );
    
        for ( sort { $b <=> $a } keys %hours ) {
            return $hours{$_} if (localtime)[2] >= $_;
        }
        return 'night';
    }
    
    # constructor that parses arguments w/ basic validation
    sub new_with_options ($class) {
        Getopt::Long::GetOptionsFromArray(
          \@ARGV, $o, @opt_spec
        ) or die "bad options\n";
        die "Missing --name\n" unless $o->name;
        return $o;
    }
    
    sub run ($self) {
        printf "Good %s, %s!\n",
               time_of_day(), $self->name;
    
        if ( defined $self->water and $self->water->@* ) {
            say 'What kind of water would you like?';
            say "- $_" for $self->water->@*;
        }
        return;
    }
    
    package main;
    
    main() unless caller;
    
    sub main { modulinh2o->new_with_options->run() }

    And run:

    ./modulinh2o.pm --name Aquarius \
      --water sparkling --water still

    This is much short­er than the Moo-​based mod­uli­no from three weeks ago, but it also does­n’t do as much. There’s no sup­port for using com­ma sep­a­ra­tors to pass mul­ti­ple val­ues to a sin­gle argu­ment. Worse, there’s no auto­mat­ic help text if one pass­es the wrong options.

    Both are fix­able, as we’ll see in a moment. Still, you end up hav­ing to write the POD your­self, print­ed out with var­i­ous invo­ca­tions of Pod::Usage​’s pod2usage()function.

    An ounce of script is worth a gallon of documentation

    Here’s a full exam­ple that adds both --helpand --mancom­mand line options, as typ­i­cal­ly pro­vid­ed by tra­di­tion­al Getopt::Long-based scripts:

    #!/usr/bin/env perl
    
    use v5.38;
    
    package modulinh2o2;
    
    use Getopt::Long qw();
    use Util::H2O::More v0.4.2 qw(h2o opt2h2o);
    use Pod::Usage;
    
    my @opt_spec = qw(
        name=s
        water=s@
    
        help
        man
    );
    my $o = h2o -classify => __PACKAGE__, {},
            opt2h2o(@opt_spec);
    
    sub time_of_day {
        my %hours = (
             5 => 'morning',
            12 => 'afternoon',
            17 => 'evening',
            21 => 'night',
        );
    
        for ( sort { $b <=> $a } keys %hours ) {
            return $hours{$_} if (localtime)[2] >= $_;
        }
        return 'night';
    }
    
    # different parameter mixes for pod2usage()
    my %pod2usage_opt = (
        cmdline => {
            -exitval  => 2,
            -verbose  => 99,
            -sections => 'USAGE/Command line',
            -message  => 'Use --help to list options',
        },
        opts => {
            -exitval  => 0,
            -verbose  => 99,
            -sections => ['USAGE/Command line', 'OPTIONS'],
        },
        man => {
            -exitval => 0,
            -verbose => 2,
        },
    );
    
    sub new_with_options ($class) {
        Getopt::Long::GetOptionsFromArray(
          \@ARGV, $o, @opt_spec
        ) or pod2usage( %pod2usage_opt{cmdline} );
    
        pod2usage( $pod2usage_opt{opts} ) if $o->help;
        pod2usage( $pod2usage_opt{man} )  if $o->man;
        pod2usage( %pod2usage_opt{cmdline},
          -message => 'Missing --name',
        ) unless $o->name;
    
        # default values for the water parameter
        $o->water(
              ( defined $o->water and $o->water->@* )
            ? [ split /,/, join q{,}, $o->water->@* ]
            : [ qw(
                still
                sparkling
                tap
            ) ] );
    
        return $o;
    }
    
    sub run ($self) {
        printf "Good %s, %s!\n",
               time_of_day(), $self->name;
    
        say 'What kind of water would you like?';
        say "- $_" for $self->water->@*;
    
        return;
    }
    
    package main;
    
    main() unless caller;
    
    sub main { modulinh2o2->new_with_options->run() }
    
    # the rest below is documentation
    
    __END__
    
    =head1 NAME
    
    modulinh2o2 - demo of a modulino using Util::H2O::More
    
    =head1 USAGE
    
    =head2 Command line
    
        modulinh2o2.pm [options]
    
    =head2 Perl
    
        use modulinh2o2;
        my $water = modulinh2o2->new(
            name  => 'Aquarius',
            water => [ qw(
                sparkling
                still
                tap
            ) ],
        );
        $water->run;
    
    =head1 OPTIONS
    
    =over
    
    =item B<--name>
    
    Your name here! (required)
    
    =item B<--water>
    
    Type of water to serve. May be specified multiple times, either by repeating the option or separated by commas.
    
    Takes an arrayref when used as a method or construction parameter.
    
    Default values:
    
    =over
    
    =item still
    
    =item sparkling
    
    =item tap
    
    =back
    
    =item B<--help>
    
    Displays a brief help message.
    
    =item B<--man>
    
    Display full documentation as a manual page.
    
    =back
    
    =head1 DESCRIPTION
    
    A sample L<Util::H2O::More> modulino that prints your name, what part of the day it is, and a menu of water choices.
    
    =head1 METHODS
    
    =head2 new
    
    Constructor that takes the above L</OPTIONS> but without the preceding C<-->.
    
    =head2 new_with_options
    
    Alternate constructor that receives its parameters from C<@ARGV>.
    
    =head2 run
    
    Prints out a greeting along with a selection of refreshing drinks.
    
    =head2 ACCESSOR OPTIONS
    
    All of the L</OPTIONS> above are also available as method accessors but without the preceding C<-->.
    
    =head1 FUNCTIONS
    
    =head2 time_of_day
    
    Returns the period of the day based on local time. Possible values are:
    
    =over
    
    =item morning
    
    =item afternoon
    
    =item evening
    
    =item night
    
    =back
    
    =head1 AUTHOR
    
    Mark Gardner <[email protected]>
    
    =head1 LICENSE AND COPYRIGHT
    
    This software is copyright (c) 2025 by Mark Gardner.
    
    This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.

    Now we can run either:

    ./modulinh2o2.pm --help

    to get a quick help mes­sage, or:

    ./modulinh2o2.pm --man

    to show the full man­u­al page.

    Yes, over half of the line count is doc­u­men­ta­tion. Even grant­i­ng this the code amount is now com­pa­ra­ble to the Moo-​based ver­sion. And you don’t get the advan­tage of MooX::Options’ declar­a­tive syntax.

    To sum­ma­rize, here’s a table chart­ing the evo­lu­tion of our modulinh2o:

    StageProsConsComplexity
    Simple script:
    Getopt2h2oonly
    * Minimal code foot­print
    * Instant acces­sors from CLI argu­ments
    * Multi-​valued options via @ syntax
    * No required-​argument enforce­ment
    * No auto-​help
    * Manual validation
    Low:
    about 20 lines, one file
    Basic mod­uli­no:
    h2o+ opt2h2o
    * Testable as a mod­ule
    * Reusable new_with_optionscon­struc­tor
    * Keeps brevi­ty vs. Moo
    * Still no comma-​separated multi-​values
    * No auto-​help on bad arguments
    Medium:
    adds pack­age struc­ture, test harness
    Full mod­uli­no w/​help & man:
    Pod::Usage inte­grat­ed
    * Support for --helpand --man
    * Comma-​separated multi-​values han­dled
    * Defaults for miss­ing --water
    * Clearer on-​boarding for new users
    * More boil­er­plate
    * Over half the file is POD
    * Still less declar­a­tive than MooX::Options
    High:
    more mov­ing parts, but user-friendly
    Util::H2O::More mod­uli­no evo­lu­tion and fea­ture trade-offs

    Seen togeth­er, these stages trace a course from a bare-​bones script to a well-​provisioned mod­uli­no. Each step adds fea­tures at the cost of a lit­tle more complexity.

    In the end, Util::H2O::More deliv­ers a lean, testable mod­uli­no with far less boil­er­plate than Moo–but also few­er built‑in niceties. If you val­ue speed from idea to work­ing script and can live with­out declar­a­tive option han­dling, it’s a com­pelling choice. For more struc­tured needs, MooX::Options still has the edge. Either way, the path from script to production-​ready mod­uli­no is short­er than you think.

    The best tool is the one that flows from idea to ​“it works” in a sin­gle pour.

  • Kaiju Boss Battle: A Dist::Zilla Journey from Chaos to Co-Op

    Kaiju Boss Battle: A Dist::Zilla Journey from Chaos to Co-Op

    Last week I wrote about devel­op­ing a Perl mod­ule enabling me to out­put log entriesto macOS’ uni­fied log­ging sys­tem. This week­end’s adven­ture involved port­ing that mod­ule’s man­u­al process­es. These process­es includ­ed depen­den­cy man­age­ment, doc­u­men­ta­tion sync­ing, ver­sion bump­ing, and release. The goal was to make every­thing more auto­mat­ed and repeatable.

    And maybe even… monstrous.

    I was already famil­iar with the Dist::Zilla(DZil to its friends) suite of tools and plu­g­ins. I also knew that some Perl devel­op­ers view it as a huge bar­ri­er to entry. This per­cep­tion affects their will­ing­ness to con­tribute to oth­ers’ projects.

    So even though Log-​Any-​Adapter-​MacOS-​OSLogwas a small mod­ule of inter­est to a lim­it­ed cod­ing audi­ence (macOS Perl users), I thought it wise to have both a mainbranch and sep­a­rate build/mainbranch for those pro­gram­mers who want­ed to work as though things had bare­ly changed:

    • Entire source code with full POD-​formatted documentation;
    • A Makefile.PL script to gen­er­ate a portable build­ing, test­ing, and installing Makefile;
    • Plus, every Perl dis­tri­b­u­tion should sup­ply the typ­i­cal README, MANIFEST, LICENSE, and CONTRIBUTING doc­u­men­ta­tion. This is essen­tial if it’s meant for pub­lic consumption.

    A small dis­tri­b­u­tion would also give me a mod­el I can scale up for larg­er projects. At the very least, it was anoth­er learn­ing oppor­tu­ni­ty for me.

    Boy, was it.

    Why Dist::Zilla?

    Because I know it can auto­mate away the boil­er­plate code and rep­e­ti­tion of infor­ma­tion that’s unfor­tu­nate­ly nec­es­sary in a mod­ern Perl mod­ule distribution:

    • the ver­sion num­bers in every mod­ule and script
    • the README that often includes the same intro­duc­to­ry text as the main mod­ule’s documentation
    • the nam­ing, order, and con­tent of POD sec­tions (via DZil’s sis­ter suite, Pod::Weaver), some of which repeat dis­tri­b­u­tion meta­da­ta like author, ver­sion, sup­port, copy­right, license, and so on

    If you need fur­ther detail, Dan Book’s Dist::Zilla::Starteris, as its name sug­gests. an excel­lent and remark­ably thor­ough guide to the how and why of Dist::Zilla. It even cov­ers the basic struc­ture of CPAN dis­tri­b­u­tionsand the his­to­ry of Perl mod­ule author­ingtools.

    Why not something else?

    Because just as in the sto­ry of Goldilocks and the three bears, every­thing else I looked at seemed want­i­ng in some way:

    • Module::Build/​Module::Build::Tiny: Although min­i­mal, pure-​Perl, and easy to install, Module::Build prop­er still requires ship­ping extra boil­er­plate and dupli­cate meta­da­ta. ::Tiny shaves that down fur­ther but drops whole swaths of func­tion­al­i­ty. As an exam­ple, you can’t spec­i­fy at set­up time that users need a spe­cif­ic oper­at­ing sys­tem. It’s a deal-​breaker for a mod­ule that requires macOS 10.12 Sierra or newer.
    • Minilla: Opinionated con­ven­tion over con­fig­u­ra­tion, but I don’t share its opin­ions. Overriding direc­to­ry lay­out, test struc­ture, and README/​license han­dling would mean fight­ing Minilla’s defaults, DZil-​config style, with­out DZil’s plu­g­in ecosystem.
    • ShipIt: Simple, one-​file con­fig­u­ra­tion. But too sim­ple: it’s most­ly release automa­tion and not author­ing automa­tion. Everything I want­ed to tool away is still manual.
    • No or min­i­mal tool­chain: That was how I start­ed this mod­ule, with ExtUtils::MakeMaker. Totally man­u­al, with every con­trib­u­tor, includ­ing yours tru­ly, need­ing to remem­ber all the mov­ing parts by hand.

    Off to see the lizard!

    My DZil dist.inicon­fig­u­ra­tion file start­ed off sim­ply enough:

    name    = Log-Any-Adapter-MacOS-OSLog
    author  = Mark Gardner <[email protected]>
    license = Perl_5
    copyright_holder = Mark Gardner
    version = 0.0.5 ; bump as appropriate
    
    [MetaResources]
    repository.type = git
    bugtracker.web  = https://codeberg.org/mjgardner/perl-Log-Any-Adapter-MacOS-OSLog/issues
    
    [Repository]
    web = https://codeberg.org/mjgardner/perl-Log-Any-Adapter-MacOS-OSLog

    That [MetaResources]is the first evi­dence of using plu­g­ins. It pro­vides a nice tidy way to spec­i­fy the meta­da­ta. Automated tools can use this infor­ma­tion to index, exam­ine, pack­age, or install Perl distributions.

    So far so good. But then the mon­ster attacked.

    [@Filter]
    -bundle = @Basic
    -remove = GatherDir
    -remove = MakeMaker
    -remove = Readme
    ; License plugin still active here
    
    [GatherDir]
    include_dotfiles = 1
    exclude_filename = .gitignore
    exclude_filename = .build
    
    [ReadmeAnyFromPod]
    type = markdown
    filename = README.md
    location = build
    
    [CopyFilesFromBuild]
    copy = README.md
    
    ; various MakeMaker::*, Git::*, Pod::Weaver, etc. plugins

    I was get­ting errors from the dzil --buildcom­mand as it attempt­ed to add the same file mul­ti­ple times. These were gen­er­at­ed files com­mit­ted to the mainver­sion con­trol branch and a build/mainbranch for non-​DZil contributors.

    This brought me to a dead stop. I went down a rabbit-​hole exam­in­ing built files from rsync(1)with [Run::AfterBuild]., com­par­ing them to the branch I had set up.

    Plus my .perlcriticrcfile was dis­ap­pear­ing from the build arti­facts despite that instruc­tion to [GatherDir]to include dot­files.

    “Let them fight.”

    LICENSE to kill (files)

    Eventually I traced the LICENSE file dupli­ca­tion to duel­ing DZil plu­g­ins. The afore­men­tioned [GatherDir]was duti­ful­ly copy­ing the file from my work­ing root even as the [License]plu­g­in was gen­er­at­ing it. I did­n’t want to lose the lat­ter source of what­ev­er license I hap­pened to be using to dis­trib­ute this code.

    And .perlcriticrc? It turns out the [PruneCruft]plu­g­in does­n’t lis­ten to its friend [GatherDir]and was hoover­ing it away.

    In the end, I had to expand my manually-​configured plu­g­in ros­ter a lit­tle to keep them from fight­ing over what files came from where:

    [@Filter]
    -bundle = @Basic
    -remove = GatherDir
    -remove = PruneCruft
    -remove = MakeMaker
    -remove = Readme
    
    [PruneCruft]
    except = .perlcriticrc
    
    [GatherDir]
    include_dotfiles = 1
    exclude_filename = .gitignore
    exclude_filename = .build
    exclude_filename = LICENSE
    
    [ReadmeAnyFromPod]
    type            = markdown
    filename        = README.md
    
    [CopyFilesFromBuild]
    copy = README.md
    copy = LICENSE

    With these tweaks to Log-​Any-​Adapter-​MacOS-​OSLog’s dist.ini:

    • [License]gen­er­ates its file,
    • [CopyFilesFromBuild]copies it along with the README into the root for my commit,
    • And [GatherDir]does­n’t stomp on it like so many Tokyo city blocks.

    The build/mainbranch for non-​DZil con­trib­u­tors would con­tain exact­ly what they and I expect­ed. It would be a match of the DZil work­ing copy plus arti­facts, git cloneable with no surprises.

    Lessons learned, and what’s next?

    I want­ed to serve two styles of devel­op­ment and had signed myself up for a com­pli­cat­ed author­ing process. I now have more knowl­edge of which plu­g­in runs in each phaseof the build. This allows me to decide between gen­er­at­ed and version-​controlled files.

    CPAN has a rich vari­ety of per­son­al Dist::Zilla::PluginBundle::s fac­tor­ing indi­vid­ual authors’ pref­er­ences into a sin­gle place. Those authors don’t have to copy-​and-​paste DZil con­fig­u­ra­tions around. It’s past time for me to mint one of my own bun­dles for more con­sis­tent and well-​understood Perl dis­tri­b­u­tions. Then I can start spin­ning up new projects with­out revis­it­ing the same pain.


    This week­end has brought on a men­tal shift. I moved from fight­ing DZil’s defaults to mak­ing it work myway. I also found sat­is­fac­tion in a pre­dictable, minimal-​effort Perl release pipeline.

    The mon­ster was just a guy in a rub­ber suit all along.