App-Ariza.git | t/ | 16-ci.rakutest


use v6.d;
use Test;

use App::Ariza::CI;
use App::Ariza::Config;
use App::Ariza::Resources;
use App::Ariza::Tools;
use App::Ariza::Versions;

plan 11;

sub tmp-dir(--> IO::Path) {
    my $dir = $*TMPDIR.add("ariza-ci-{$*PID}-{(^1_000_000).pick}");
    $dir.mkdir;
    $dir;
}

#| A config from TOML text, written and read back the way a real one is.
sub config(Str $toml --> App::Ariza::Config) {
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }
    $dir.add('ariza.toml').spurt($toml);
    App::Ariza::Config.load($dir);
}

#| Pins as a fixture rather than ariza's own resources/versions.toml: a
#| golden workflow is a statement about ariza's templates, and it should
#| fail when one of those changes, not when the Rakudo pin is bumped.
sub versions(--> App::Ariza::Versions) {
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }
    $dir.add('versions.toml').spurt(q:to/TOML/);
    sqlcipher = "4.14.0"

    [rakudo]
    version  = "2026.07"
    revision = "01"
    TOML
    App::Ariza::Versions.load($dir.add('versions.toml'));
}

# The same values App-ExampleApp's manifest carries, for the same reason
# t/15 keeps its own copy: this file is about ariza's templates, not
# about a sibling app's current platform list.
my constant EXAMPLEAPP = q:to/TOML/;
[app]
name = "App::ExampleApp"
exec = "exampleapp"
display = "Example App"

[bundle]
platforms = ["macos-arm64", "linux-x86_64-glibc", "windows-x86_64"]
native = ["notcurses", "sqlcipher"]

[installer]
repo = "example-org/App-ExampleApp"
TOML

my $CFG  = config(EXAMPLEAPP);
my $VERS = versions();

# Pinned rather than read off the running distribution: under `prove6
# -Ilib` there is no metadata and the answer is 'dev', under `-I.` it is
# the real version, and a golden file cannot be both.
my constant ARIZA-VERSION = '0.0.1';

sub ctx(App::Ariza::Config $config = $CFG --> Hash) {
    App::Ariza::CI.context(:$config, :versions($VERS),
                           :ariza-version(ARIZA-VERSION))
}

sub render(Str $template, App::Ariza::Config $config = $CFG --> Str) {
    App::Ariza::CI.render(:$template, |ctx($config))
}

my %CTX     = ctx();
my $RELEASE = render('release.yml.j2');
my $TEST    = render('test.yml.j2');

sub golden-path(Str $name --> IO::Path) {
    checkout-root().add('t/golden').add("ci-$name")
}

subtest 'an app gets one build lane per platform it declares', {
    plan 8;

    is App::Ariza::CI.lanes-for($CFG).map(*.<job>).List,
        ('bundle-macos-arm64', 'bundle-linux-x86_64-glibc',
         'bundle-windows-x86_64'),
        'three declared platforms, three jobs, in manifest order';

    is App::Ariza::CI.lane-slugs,
        ('linux-x86_64-glibc', 'macos-arm64', 'windows-x86_64'),
        'and those are the only three ariza can scaffold a lane for';

    my $one = config(q:to/TOML/);
    [app]
    name = "A"
    exec = "a"
    display = "A"
    [bundle]
    platforms = ["macos-arm64"]
    TOML
    is App::Ariza::CI.lanes-for($one).map(*.<job>).List,
        ('bundle-macos-arm64',), 'a one-platform app gets one lane';

    my $one-release = render('release.yml.j2', $one);
    ok $one-release.contains('bundle-macos-arm64:'), 'and one build job';
    nok $one-release.contains('bundle-windows-x86_64'),
        'with no trace of the platforms it did not ask for';
    is +$one-release.comb(/ ^^ '      - bundle-' /), 1,
        'and a needs: list with exactly one entry';

    # A platform ariza cannot build a bundle for at all (rakudo.org
    # publishes no binary for it) must not quietly vanish from the
    # workflow: silently dropping it ships a release missing a platform
    # the author asked for, which is why bundle.platforms rejects an
    # unknown slug rather than warning.
    my $unbuildable = config(q:to/TOML/);
    [app]
    name = "A"
    exec = "a"
    display = "A"
    [bundle]
    platforms = ["macos-arm64", "linux-aarch64-musl"]
    TOML
    throws-like { App::Ariza::CI.lanes-for($unbuildable) }, Exception,
        message => /'no CI build lane' .* 'linux-aarch64-musl'/,
        'a slug with no lane is named, not skipped';
    throws-like { App::Ariza::CI.lanes-for($unbuildable) }, Exception,
        message => /'linux-x86_64-glibc, macos-arm64, windows-x86_64'/,
        'and the message says which slugs do have one';
};

subtest 'the context carries the app and the pins, not the machine', {
    plan 11;

    is %CTX<app_name>, 'App::ExampleApp', 'the dist name';
    is %CTX<app_exec>, 'exampleapp', 'the launcher name';
    is %CTX<repo>, 'example-org/App-ExampleApp', 'where releases are published';
    is %CTX<rakudo_tag>, '2026.07-01', 'the runtime pin a bundle embeds';
    is %CTX<rakudo_revision_int>, 1,
        'as a number, because the release index records it as one';
    is %CTX<sqlcipher_version>, '4.14.0', 'and the SQLCipher pin';
    is %CTX<job_names>.List,
        ('bundle-macos-arm64', 'bundle-linux-x86_64-glibc',
         'bundle-windows-x86_64'),
        'the jobs publish will have to wait for';
    ok %CTX<smoke_installer>,
        'an app with a repo gets the installer smoke';
    is %CTX<smoke_job_names>.List,
        ('smoke-installer-macos-arm64', 'smoke-installer-linux-x86_64-glibc',
         'smoke-installer-windows-x86_64'),
        'one clean-runner smoke per declared platform, in manifest order';
    is %CTX<no_smoke_platforms>.List, (),
        'and every platform ariza can scaffold a lane for also gets one';

    # A workflow is read by strangers on GitHub; nothing about the
    # machine that generated it may survive into one.
    nok ($RELEASE, $TEST).first(*.contains($*HOME.absolute)),
        'and no path from the machine that rendered it appears anywhere';
};

subtest 'the rendered workflows match their golden files', {
    plan 3;

    # Set ARIZA_REGENERATE_GOLDEN=1 to rewrite these after an intentional
    # template change; read the diff before you commit it.
    my $regenerate = ?%*ENV<ARIZA_REGENERATE_GOLDEN>;

    for App::Ariza::CI.workflows -> %w {
        my $text = render(%w<template>);
        my $golden = golden-path(%w<output>);
        ensure-dir($golden.parent);
        $golden.spurt($text) if $regenerate || !$golden.f;

        # Read as bytes: IO::Handle normalises CRLF to LF in text mode,
        # and a workflow file is LF whatever machine wrote it.
        my $expected = $golden.slurp(:bin).decode('utf8');
        my $matched = is $text, $expected,
            "{%w<output>} renders byte-for-byte as committed";
        # A difference that is nothing but line endings is git, not
        # ariza: this distribution ships .gitattributes with `* -text` so
        # that no checkout rewrites these files, and a working tree
        # created before that existed still has the converted bytes.
        diag 'the only difference is CRLF — the golden file was converted'
           ~ ' on checkout; see .gitattributes'
            if !$matched && $expected.subst("\r\n", "\n", :g) eq $text;
    }

    # `${{ ... }}` is GitHub's own syntax and is meant to survive; a bare
    # `{{` or a `{%` is a template that did not render.
    nok ($RELEASE, $TEST).first(/ '{%' | <!after '$'> '{{' /),
        'and no unrendered Jinja2 delimiter survives into either';
};

subtest 'release.yml is a workflow GitHub will accept', {
    plan 15;

    ok $RELEASE.contains("\nname: release\n"), 'it is called release';
    ok $RELEASE.contains("\non:\n"), 'it has triggers';
    ok $RELEASE.contains("  workflow_dispatch:\n"),
        'a dispatch trigger, so a lane can be iterated without a tag';
    ok $RELEASE.contains("      ref:\n"), 'with a ref input to iterate on';
    ok $RELEASE.contains("  push:\n    tags:\n      - 'v*'\n      - '[0-9]*.[0-9]*.[0-9]*'\n"),
        'and tag triggers for both shapes a release tag arrives in '
        ~ '(v-prefixed by hand, bare by mi6), which are the ones that publish';

    ok $RELEASE.contains("\npermissions:\n  contents: write\n"),
        'it may write releases, and nothing else';
    ok $RELEASE.contains("\nenv:\n  LANG: en_US.UTF-8\n  LC_ALL: en_US.UTF-8\n"),
        'and runs under a UTF-8 locale, as every workflow in this tree does';

    # GitHub expression syntax and Jinja2 both spell themselves with
    # braces; the templates escape the former so the latter leaves it
    # alone. If that ever stops working these come out empty.
    ok $RELEASE.contains('ref: ${{ inputs.ref || github.ref }}'),
        'the dispatch ref reaches checkout intact';
    ok $RELEASE.contains('tag_name: ${{ github.ref_name }}'),
        'and the tag reaches the release';

    is +$RELEASE.comb(/ ^^ '  bundle-' \N+ ':' $$ /), 3,
        'three build jobs';
    ok $RELEASE.contains("  publish:\n"), 'a publish job';
    is +$RELEASE.comb(/ ^^ '  smoke-installer-' \N+ ':' $$ /), 3,
        'and one clean-runner installer smoke per declared platform';
    ok $RELEASE.contains("  smoke-installer-macos-arm64:\n"),
        'the macOS one, which is the only BSD-userland coverage install.sh gets';
    ok $RELEASE.contains("  smoke-installer-linux-x86_64-glibc:\n"),
        'the Linux one';
    ok $RELEASE.contains("  smoke-installer-windows-x86_64:\n"),
        'and the Windows one, which is install.ps1\'s only clean-machine coverage';
};

subtest 'nothing is published until a tag says so', {
    plan 6;

    my @needs = $RELEASE.lines
        .grep({ .starts-with('      - bundle-') }).map(*.trim.substr(2));
    is @needs, %CTX<job_names>.List,
        'publish waits for every build lane, by name';

    is +$RELEASE.comb(/ ^^ '    if: startsWith(github.ref, ' \' 'refs/tags/' \' ')' $$ /), 4,
        'and publish and every installer smoke are tag-gated';

    is +$RELEASE.comb(/ ^^ '    needs: publish' $$ /), 3,
        'every installer smoke runs after the release exists';

    # A dispatch run must be free to fail: that is the whole point of
    # having one.
    ok $RELEASE.contains('workflow_dispatch:')
        && $RELEASE.contains('startsWith(github.ref'),
        'so a dispatch run builds and smokes and stops there';

    ok $RELEASE.contains('uses: softprops/action-gh-release@v3'),
        'the release is cut with the action the rest of this tree uses';
    ok $RELEASE.contains('sha256sum -c'),
        'and the digests are checked before they are published';
};

subtest 'installer smoke is scoped to the platforms actually declared', {
    plan 8;

    my $linux-only = config(q:to/TOML/);
    [app]
    name = "A"
    exec = "a"
    display = "A"
    [bundle]
    platforms = ["linux-x86_64-glibc"]
    [installer]
    repo = "example-org/A"
    TOML
    my $linux-release = render('release.yml.j2', $linux-only);

    is +$linux-release.comb(/ ^^ '  smoke-installer-' \N+ ':' $$ /), 1,
        'a one-platform app gets exactly one clean-runner installer smoke';
    ok $linux-release.contains('  smoke-installer-linux-x86_64-glibc:'),
        'the one it declared';
    nok $linux-release.contains('smoke-installer-macos-arm64'),
        'with no trace of a platform it did not build';
    nok $linux-release.contains('smoke-installer-windows-x86_64'),
        'not even a comment naming it, since a build lane exists for macOS'
      ~ ' and Windows too and each has smoke coverage of its own';

    # No installer.repo at all: the whole feature is off, exactly as it is
    # for an app with only one declared platform and a repo.
    my $one = config(q:to/TOML/);
    [app]
    name = "A"
    exec = "a"
    display = "A"
    [bundle]
    platforms = ["macos-arm64"]
    TOML
    my $one-release = render('release.yml.j2', $one);
    nok $one-release.contains('smoke-installer-'),
        'and no installer.repo means no installer smoke at all';

    # Every platform ariza can build a lane for also has a smoke recipe
    # today, so the "no clean runner yet" comment never actually renders --
    # this just pins that it stays silent rather than appearing for no
    # reason.
    nok $RELEASE.contains('No clean-runner installer smoke'),
        'and the release notes say nothing about missing smoke coverage'
      ~ ' when every declared platform has it';

    is App::Ariza::CI.context(:config($linux-only))<smoke_job_names>.List,
        ('smoke-installer-linux-x86_64-glibc',),
        'the context carries just that one job id';
    is App::Ariza::CI.context(:config($linux-only))<no_smoke_platforms>.List,
        (), 'and no platform is reported as missing smoke coverage';
};

subtest 'each lane installs what ariza needs on that platform', {
    plan 15;

    ok $RELEASE.contains("    runs-on: macos-latest\n"), 'macOS lane';
    ok $RELEASE.contains('brew install sqlcipher'),
        'takes SQLCipher from Homebrew, which is where ariza looks';

    ok $RELEASE.contains('    container: quay.io/pypa/manylinux_2_28_x86_64'),
        'the Linux lane builds in manylinux_2_28';
    ok $RELEASE.contains('dnf install -y python3 tcl openssl3-devel'),
        'with the packages a SQLCipher source build needs';
    ok $RELEASE.contains('https://rakudo.org/dl/rakudo'),
        'and installs Rakudo from the release index by hand,';
    ok $RELEASE.contains('"ver") == "2026.07"')
        && $RELEASE.contains('"build_rev", -1)) == 1'),
        'at exactly the version and revision ariza pins';
    ok $RELEASE.contains('/v4.14.0.tar.gz'),
        'and builds the pinned SQLCipher rather than a distro package';

    ok $RELEASE.contains("    runs-on: windows-latest\n"), 'the Windows lane';
    ok $RELEASE.contains('pacman.exe -Sy --noconfirm --needed mingw-w64-ucrt-x86_64-sqlcipher'),
        'sources SQLCipher from the MSYS2 installation every windows runner'
      ~ ' already has';
    ok $RELEASE.contains("\$lib = 'C:\\msys64\\ucrt64\\bin'")
        && $RELEASE.contains("Test-Path (Join-Path \$lib 'libsqlcipher*.dll')"),
        'asserting the DLL is there under whichever of its names, since'
      ~ ' MSYS2 calls it libsqlcipher-0.dll and vcpkg called it sqlcipher.dll';
    ok $RELEASE.contains('"SQLCIPHER_LIB_DIR=$lib"'),
        'and names the directory, which is the contract App::Ariza::Native'
      ~ ' documents for Windows';

    # The UCRT build is the whole point of the change: an MSVC-built
    # SQLCipher imports vcruntime140.dll, which is not part of Windows,
    # and a runner cannot see that because every runner has the
    # redistributable installed.
    ok $RELEASE.contains('vcruntime140.dll'),
        'the lane says in its own comments why it is not vcpkg any more';

    # The vcpkg port was a source build; the MSYS2 package is prebuilt,
    # so the cache that existed only to avoid that build is gone with it.
    nok $RELEASE.contains('vcpkg install'),
        'nothing builds SQLCipher from source on Windows any more';
    nok $RELEASE.contains('actions/cache'),
        'and no lane caches anything, on any platform';

    is +$RELEASE.comb(/ 'ariza smoke --archive=' /), 3,
        'and every lane smokes what it built before uploading it';
};

subtest 'where the workflows install ariza from', {
    plan 8;

    ok $RELEASE.contains(q[run: zef install --/test 'App::Ariza:ver<0.2.2+>:auth<zef:apogee>']),
        'fez by default, with its version floor and author pinned';
    nok $RELEASE.contains('zef install --/test App::Ariza'),
        'and never emits the unqualified package name';
    ok $RELEASE.contains('#   run: zef install --/test https://github.com/m-doughty/App-Ariza.git'),
        'with the repository beside it as a comment, for the bootstrap';
    ok $RELEASE.contains("Scaffolded by ariza {ARIZA-VERSION}."),
        'and the version that scaffolded the file, so provenance is visible';

    my $git = config(q:to/TOML/);
    [app]
    name = "A"
    exec = "a"
    display = "A"
    [bundle]
    platforms = ["macos-arm64"]
    [ci]
    ariza-source = "https://github.com/m-doughty/App-Ariza.git"
    TOML
    is $git.ci-ariza-source, 'https://github.com/m-doughty/App-Ariza.git',
        'ci.ariza-source is read off the manifest';

    my $from-git = render('release.yml.j2', $git);
    ok $from-git.contains('run: zef install --/test https://github.com/m-doughty/App-Ariza.git'),
        'and becomes the command';
    ok $from-git.contains(q[#   run: zef install --/test 'App::Ariza:ver<0.2.2+>:auth<zef:apogee>']),
        'with the pinned fez identity demoted to the comment';

    throws-like {
        config("[app]\nname = \"A\"\nexec = \"a\"\ndisplay = \"A\"\n"
             ~ "[bundle]\nplatforms = [\"macos-arm64\"]\n"
             ~ "[ci]\nariza-source = \"\"\n")
    }, Exception, message => /'ci.ariza-source must not be empty'/,
        'and an empty one is refused rather than rendered';
};

subtest 'the [ci] table follows the house rules', {
    plan 3;

    my $unknown = config(q:to/TOML/);
    [app]
    name = "A"
    exec = "a"
    display = "A"
    [bundle]
    platforms = ["macos-arm64"]
    [ci]
    ariza-source = "fez"
    "// note" = "ignored silently"
    future-key = "warns"
    TOML
    is $unknown.warnings.List, ("unknown key 'ci.future-key' in ariza.toml (ignored)",),
        'an unrecognised key warns and loading continues';
    is $unknown.ci-ariza-source, 'fez', 'while the keys it knows are still read';

    throws-like {
        config("[app]\nname = \"A\"\nexec = \"a\"\ndisplay = \"A\"\n"
             ~ "[ci]\nariza-source = 3\n")
    }, Exception, message => /'ci.ariza-source must be a string'/,
        'and a wrong type dies naming the dotted path';
};

subtest 'test.yml is the house shape, and says it is yours', {
    plan 6;

    ok $TEST.contains("\nname: test\n"), 'it is called test';
    ok $TEST.contains("        os: [ubuntu-latest, macos-latest, windows-latest]\n"),
        'all three runners, as every other test.yml in this tree';
    ok $TEST.contains('run: prove6 -I. t'),
        'and runs the suite the same way';
    ok $TEST.contains('tags-ignore'),
        'a tag push does not re-run the tests -- release.yml has that covered';

    ok $TEST.contains('written only when it is absent'),
        'the file says it will not be regenerated';
    ok $TEST.contains('notcurses, sqlcipher'),
        'and names the native libraries it deliberately does not install';
};

subtest 'write puts them in .github/workflows, once', {
    plan 9;
    my $app = tmp-dir;
    LEAVE { rm-rf($app) }
    my $dir = $app.add('.github/workflows');

    my @first = App::Ariza::CI.write(:out-dir($dir), :config($CFG),
                                     :versions($VERS),
                                     :ariza-version(ARIZA-VERSION));
    is @first.map(*.<output>).List, ('test.yml', 'release.yml'),
        'both workflows, in write order';
    is @first.map(*.<action>).List, ('wrote', 'wrote'),
        'both written into a directory that did not exist';
    ok $dir.d, 'which was created -- a repository with no CI has no such directory';

    # A hand-maintained test workflow is not ariza's to clobber, and
    # release.yml is derived from bundle.platforms, so it must follow one.
    $dir.add('test.yml').spurt("# mine now\n");
    my @again = App::Ariza::CI.write(:out-dir($dir), :config($CFG),
                                     :versions($VERS),
                                     :ariza-version(ARIZA-VERSION));
    is @again.map(*.<action>).List, ('skipped', 'wrote'),
        'a second run keeps test.yml and rewrites release.yml';
    is $dir.add('test.yml').slurp, "# mine now\n", 'the edited file is untouched';
    is $dir.add('release.yml').slurp, $RELEASE, 'and release.yml is regenerated';

    my @forced = App::Ariza::CI.write(:out-dir($dir), :config($CFG),
                                      :versions($VERS),
                                      :ariza-version(ARIZA-VERSION), :force);
    is @forced.map(*.<action>).List, ('wrote', 'wrote'),
        'until --force says otherwise';
    is $dir.add('test.yml').slurp, $TEST, 'which restores the house shape';

    my $bare = config(q:to/TOML/);
    [app]
    name = "A"
    exec = "a"
    display = "A"
    TOML
    throws-like {
        App::Ariza::CI.write(:out-dir($app.add('none')), :config($bare),
                             :versions($VERS))
    }, Exception, message => /'declares no bundle.platforms'/,
        'an app with no platforms is told there is nothing to build';
};