diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cce2d161..8bb12e04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,6 +185,15 @@ jobs: println(io, "R_APT_VERSION=", pins["runtimes"]["r"]["apt_version"]) println(io, "BUN_VERSION=", pins["toolchain"]["bun"]["version"]) println(io, "CUTADAPT_SPEC=cutadapt==", pins["tools"]["cutadapt"]["version"]) + println(io, "MULTIQC_SPEC=multiqc==", pins["tools"]["multiqc"]["version"]) + # FastQC ships one platform-neutral zip, so its archive hangs off the + # "any" key rather than a per-platform one, and it cannot join the loop + # below. test_install_pins.jl asserts that shape, so a pin file that + # grew a linux-x86_64 fastqc entry would fail there before it failed here. + fastqc = pins["tools"]["fastqc"]["archives"]["any"] + println(io, "FASTQC_VERSION=", pins["tools"]["fastqc"]["version"]) + println(io, "FASTQC_URL=", fastqc["url"]) + println(io, "FASTQC_SHA256=", fastqc["sha256"]) for tool in ("vsearch", "swarm") archive = pins["tools"][tool]["archives"]["linux-x86_64"] println(io, uppercase(tool), "_VERSION=", pins["tools"][tool]["version"]) @@ -323,6 +332,25 @@ jobs: rm -rf swarm.tar.gz "swarm-$SWARM_VERSION-linux-x86_64" swarm --version + - name: Install multiqc + run: pip install "$MULTIQC_SPEC" + + # FastQC is a Perl launcher round a Java jar, so it is installed as a directory + # rather than a single binary: the launcher resolves its jars relative to its own + # location, and moving it out of FastQC/ breaks it. Unpacked to /opt and reached + # through a symlink, which is what config/ci/tools.yml means by `path: "fastqc"`. + # The launcher runs `java` off PATH, so `fastqc --version` proves both that the + # install landed and that a JRE is present on the runner. + - name: Install fastqc + run: | + curl -sSL -o fastqc.zip "$FASTQC_URL" + echo "$FASTQC_SHA256 fastqc.zip" | sha256sum -c - + sudo unzip -q -d /opt fastqc.zip + rm -f fastqc.zip + sudo chmod +x /opt/FastQC/fastqc + sudo ln -sf /opt/FastQC/fastqc /usr/local/bin/fastqc + fastqc --version + - name: Instantiate Julia project run: julia --project=. -e 'import Pkg; Pkg.instantiate()' diff --git a/test/integration/test_pipeline.jl b/test/integration/test_pipeline.jl index a11e7c58..27cce31b 100644 --- a/test/integration/test_pipeline.jl +++ b/test/integration/test_pipeline.jl @@ -52,7 +52,7 @@ if isdir(test_study_dir) for entry in readdir(test_study_dir; join=true) isdir(entry) || continue - for subdir in ("cutadapt", "dada2", "swarm", "vsearch", "merged") + for subdir in ("QC", "cutadapt", "dada2", "swarm", "vsearch", "merged") d = joinpath(entry, subdir) isdir(d) && rm(d; recursive=true) end @@ -103,6 +103,25 @@ continue end + # Stage 0: FastQC/MultiQC over the raw reads. The server runs this as an + # independent stage rather than a link in the chain, so nothing downstream + # consumes it -- which is exactly why it needs asserting here. Until issue #30 + # CI installed neither tool, so the whole prefilter QC stage, its flag plumbing + # out of pipeline.yml and its provenance capture had no coverage at all. + multiqc(project) + qc_dir = joinpath(project.dir, "QC") + @test isfile(joinpath(qc_dir, "multiqc_report.html")) + for logname in ("fastqc.log", "multiqc.log") + p = joinpath(qc_dir, "logs", logname) + @test isfile(p) + # Non-empty AND carrying the invocation marker. An empty log is what a stage + # that made its log directory and then died leaves behind, and it would + # satisfy `isfile` alone; the marker line is the provenance record itself, + # the same one the DADA2 assertions below grep for. + @test filesize(p) > 0 + @test any(l -> startswith(l, "[MetaManifold] cmd: "), readlines(p)) + end + # Stage 1: cutadapt trimmed = cutadapt(project) @test isdir(trimmed.dir) diff --git a/test/unit/test_install_pins.jl b/test/unit/test_install_pins.jl index 9344b6a2..fe6eadc1 100644 --- a/test/unit/test_install_pins.jl +++ b/test/unit/test_install_pins.jl @@ -194,9 +194,43 @@ end @test occursin("tool_versions.yml", runs) @test occursin("sha256sum -c", runs) - for tool in ("vsearch", "swarm") + # fastqc and multiqc joined this list with issue #30. Leaving them out would + # have made the guard vacuous for exactly the two tools it had just been + # extended to cover -- the shape that let the gap open in the first place. + for tool in ("vsearch", "swarm", "fastqc", "multiqc") @test !occursin(pins["tools"][tool]["version"], runs) end + + # And the gap itself: every tool the pipeline shells out to must actually be + # installed by the workflow, which is what issue #30 found missing for fastqc + # and multiqc. + # + # The assertion is on the DEREFERENCE of each exported pin, not on the tool's + # name. Written the obvious way -- does "fastqc" appear anywhere in the run + # blocks -- this guard passes with the install step deleted, because the + # pin-export block above mentions every tool by name whether or not anything + # installs it. That version was written here, and deleting the fastqc step did + # not redden it. `$FASTQC_URL` appears only where a step consumes the pin. + # raw"..." rather than "\$...": the string is a shell variable reference to be + # found verbatim in the YAML, never a Julia interpolation, and config/ci/lint_source.jl + # rejects an escaped `\$` in an interpolating string precisely to force that + # distinction to be stated. The two forms carry the same bytes; only one says why. + for (tool, ref) in (("cutadapt", raw"$CUTADAPT_SPEC"), + ("multiqc", raw"$MULTIQC_SPEC"), + ("fastqc", raw"$FASTQC_URL"), + ("vsearch", raw"$VSEARCH_URL"), + ("swarm", raw"$SWARM_URL")) + @test occursin(ref, runs) + end + + # The two archive tools that are not a single binary still have to end up on + # PATH, because config/ci/tools.yml resolves every tool by bare name. + for tool in ("vsearch", "swarm", "fastqc") + @test occursin("/usr/local/bin/$tool", runs) + end + + # cd-hit is the one tool CI does not pin, so it is matched on the apt line. + @test occursin("apt-get install -y cd-hit", runs) end @testset "a required check name is a stable identifier" begin diff --git a/test/unit/test_provenance.jl b/test/unit/test_provenance.jl index 6f13306a..42e66205 100644 --- a/test/unit/test_provenance.jl +++ b/test/unit/test_provenance.jl @@ -424,16 +424,37 @@ canned(out::String, err::String = "", code::Int = 0) = ## Live probes. Gated, so that the suite passes on a machine with neither the # binaries nor the R packages installed. @testset "live probes of what is actually installed" begin + # The gate is not the same question on CI as on a laptop. ci.yml installs every + # tool in TOOL_PROBES, so an absent binary there is a provisioning regression; + # on a bare checkout it is expected. Issue #30 was this loop skipping fastqc and + # multiqc quietly for months because CI installed neither: an `@info` scrolls + # past, and a zero-failure count says nothing about which probes ran at all. + tools_must_be_present = get(ENV, "CI", "false") == "true" + for (key, probe) in PV.TOOL_PROBES - bin = PV.default_bin_resolver(key) - if isnothing(Sys.which(bin)) - @info "Provenance: skipping live probe of $key, which is not installed" - continue + # One testset per tool, so the suite's own summary names all six and shows + # per tool whether it ran, was skipped, or failed. That naming is the + # assertion; a flat loop can only report the tools it did not skip. + @testset "$key" begin + bin = PV.default_bin_resolver(key) + resolved = Sys.which(bin) + + if tools_must_be_present + # Named by the enclosing testset, so a red line reads "fastqc". + @test resolved !== nothing + end + + if isnothing(resolved) + # Broken, not passed: it shows in the summary's own column rather + # than disappearing into the count of things that went fine. + @test_skip PV.probe_tool(probe) + else + record = PV.probe_tool(probe) + @test !isempty(record.version) + @test isabspath(record.path) + @test length(record.sha256) == 64 + end end - record = PV.probe_tool(probe) - @test !isempty(record.version) - @test isabspath(record.path) - @test length(record.sha256) == 64 end r_ready = try