From e531db0265988179bd946bf4337520ef077382e9 Mon Sep 17 00:00:00 2001 From: Ehsan ESTAJI <71376358+ehsanestaji@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:29:49 +0200 Subject: [PATCH 1/3] feat: add reference workflow examples Signed-off-by: Ehsan ESTAJI <71376358+ehsanestaji@users.noreply.github.com> --- .gitignore | 4 + README.md | 4 +- docs/quickstart.md | 3 + docs/workflow-readiness.md | 6 +- examples/reference/README.md | 70 +++ examples/reference/fixtures/aliased.fa.fai | 1 + examples/reference/fixtures/aliases.tsv | 2 + examples/reference/fixtures/genes.gff3 | 3 + examples/reference/fixtures/header-only.dict | 2 + examples/reference/fixtures/invalid.fa | 2 + examples/reference/fixtures/reads.sam | 2 + examples/reference/fixtures/reference.fa.fai | 1 + .../reference/fixtures/variants/a/sites.vcf | 3 + .../reference/fixtures/variants/b/sites.vcf | 3 + .../reference/fixtures/workflow-cases.json | 81 +++ examples/reference/nextflow/main.nf | 256 +++++++++ examples/reference/nextflow/params.json | 11 + examples/reference/snakemake/Snakefile | 221 ++++++++ examples/reference/snakemake/config.json | 11 + .../reference/snakemake/scripts/downstream.py | 14 + examples/reference/snakemake/scripts/gate.py | 20 + .../reference/snakemake/scripts/publish.py | 63 +++ .../reference/snakemake/scripts/report.py | 29 + tests/python/test_reference_workflows.py | 496 ++++++++++++++++++ 24 files changed, 1305 insertions(+), 3 deletions(-) create mode 100644 examples/reference/README.md create mode 100644 examples/reference/fixtures/aliased.fa.fai create mode 100644 examples/reference/fixtures/aliases.tsv create mode 100644 examples/reference/fixtures/genes.gff3 create mode 100644 examples/reference/fixtures/header-only.dict create mode 100644 examples/reference/fixtures/invalid.fa create mode 100644 examples/reference/fixtures/reads.sam create mode 100644 examples/reference/fixtures/reference.fa.fai create mode 100644 examples/reference/fixtures/variants/a/sites.vcf create mode 100644 examples/reference/fixtures/variants/b/sites.vcf create mode 100644 examples/reference/fixtures/workflow-cases.json create mode 100644 examples/reference/nextflow/main.nf create mode 100644 examples/reference/nextflow/params.json create mode 100644 examples/reference/snakemake/Snakefile create mode 100644 examples/reference/snakemake/config.json create mode 100644 examples/reference/snakemake/scripts/downstream.py create mode 100644 examples/reference/snakemake/scripts/gate.py create mode 100644 examples/reference/snakemake/scripts/publish.py create mode 100644 examples/reference/snakemake/scripts/report.py create mode 100644 tests/python/test_reference_workflows.py diff --git a/.gitignore b/.gitignore index baa96a6..f6650df 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ target/ .worktrees/ __pycache__/ *.py[cod] +/.nextflow/ +/.nextflow.log* +/.snakemake/ +/work/ diff --git a/README.md b/README.md index 6a61a72..378c442 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,9 @@ not accepted as an annotation declaration. Try the [reference quickstart](docs/quickstart.md#reference-quickstart) for a matching reference and a deliberately mismatched dictionary, with retained -reports and a separate policy gate. +reports and a separate policy gate. The +[reference workflow examples](examples/reference/README.md) run the same +collect-then-gate pattern in Nextflow and Snakemake. Reference mode writes HTML, JSON, TSV and MultiQC-compatible JSON by default. Use `--format` to select a subset, `--write-lock` to write a diff --git a/docs/quickstart.md b/docs/quickstart.md index c1709b9..5bf0a7f 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -124,6 +124,9 @@ It is not a reusable workflow gate. Use a new output directory to repeat the example while retaining previous evidence; existing files are not overwritten. The first argument can be a quoted executable path instead of `fastaguard`. +For complete Nextflow and Snakemake examples, see the +[reference workflows](../examples/reference/README.md). + ### Write a reference lockfile `--write-lock` is a boolean switch. With `--outdir`, the lockfile is named diff --git a/docs/workflow-readiness.md b/docs/workflow-readiness.md index c670ff9..9e4edb4 100644 --- a/docs/workflow-readiness.md +++ b/docs/workflow-readiness.md @@ -30,8 +30,10 @@ starters remain local compatibility references. Reference-specific workflow components are not yet available upstream. Use the [reference quickstart](quickstart.md#reference-quickstart) to exercise -the published CLI directly; a version update to an assembly wrapper does not -add reference inputs to its interface. +the published CLI directly, or the local +[Nextflow and Snakemake reference workflows](../examples/reference/README.md) +for report retention and downstream gating. A version update to an assembly +wrapper does not add reference inputs to its interface. Historical v0.5 upstream-style validation was run on 2026-07-03 in dedicated checkouts: diff --git a/examples/reference/README.md b/examples/reference/README.md new file mode 100644 index 0000000..158164a --- /dev/null +++ b/examples/reference/README.md @@ -0,0 +1,70 @@ +# Reference workflows + +These single-bundle examples run the published FastaGuard v1.0.0 reference +command. Run them from the repository root with FastaGuard and the chosen +workflow engine on `PATH`. Each example uses the same synthetic reference and +dictionary as the [reference quickstart](../../docs/quickstart.md#reference-quickstart). + +Nextflow (DSL2): + +```bash +nextflow run examples/reference/nextflow/main.nf \ + -params-file examples/reference/nextflow/params.json +``` + +Snakemake: + +```bash +snakemake --snakefile examples/reference/snakemake/Snakefile --cores 1 \ + --configfile examples/reference/snakemake/config.json +``` + +The two checked-in configurations use different output roots. Each writes a +run directory named `exact` containing HTML, JSON, TSV, MultiQC JSON, a +reference lockfile, and `downstream.started` after the gate accepts the bundle. +The lockfile is optional and has its own output declaration. The marker is a +demonstration of a downstream task, not a biological analysis. + +Use a native JSON parameter/configuration file for a real bundle. Both engines +accept these keys: + +| Key | Meaning | +| --- | --- | +| `fasta` | Required canonical FASTA path. | +| `output_dir` | Required local or shared-filesystem output root. | +| `run_id` | Unique name for this input set; letters, digits, `_` and `-`, starting with a letter or digit. Defaults to `reference`. | +| `mode` | `report` (default) or `enforce`. | +| `fai`, `dict`, `alias_map` | Optional explicit file paths. | +| `alignments`, `variants`, `annotations` | Lists of explicit file paths; each item is a workflow file dependency. | +| `policy` | `strict`, `coordinate` (default), or `advisory`. | +| `require` | List drawn from `fai`, `dict`, `alignment`, `variants`, `annotation`. | +| `write_lock` | Boolean; when true, request a lockfile alongside the reports. | +| `binary` | Optional executable path or command; defaults to `fastaguard`. | + +An omitted optional file differs from a supplied unreadable path. All four +reports are requested together. Use a new `run_id` when any input or report +setting changes; both examples reject reuse that would contradict an existing +run. Both examples record a hidden `.reference-inputs.sha256` file and hash +the supplied inputs before reporting. Large references add a second streaming +read for that protection. Snakemake also records +`.reference-report-digests.json` and checks the published report files on +each run; missing or modified evidence is an error. + +`report` mode stops after saving the reports, including a completed FAIL +report. In `enforce` mode, a separate gate reads the JSON boolean +`gate.can_continue`. A blocked gate fails the workflow while leaving all +completed reports in `output_dir/run_id/`; no downstream marker is written. +The original FASTA and companions are never modified. + +Nextflow stages repeated companion filenames in numbered subdirectories. Its +persistence task synchronously copies the bundle to `output_dir/run_id/` and +emits a receipt before the gate can run. It accepts `-resume` for unchanged +inputs only after confirming the published bytes match. Snakemake writes +engine-managed reports to a staging directory, then publishes them without +replacing earlier completed evidence. Its gate rule owns only the acceptance +marker. These examples target one bundle on a local or shared filesystem. +Object-store publication and multiple bundles are outside their scope. + +The small [fixture manifest](fixtures/workflow-cases.json) defines eight +expected decision cases used by the engine integration tests. It is test data, +not biological validation evidence. diff --git a/examples/reference/fixtures/aliased.fa.fai b/examples/reference/fixtures/aliased.fa.fai new file mode 100644 index 0000000..4886387 --- /dev/null +++ b/examples/reference/fixtures/aliased.fa.fai @@ -0,0 +1 @@ +1 4 6 4 5 diff --git a/examples/reference/fixtures/aliases.tsv b/examples/reference/fixtures/aliases.tsv new file mode 100644 index 0000000..60578f9 --- /dev/null +++ b/examples/reference/fixtures/aliases.tsv @@ -0,0 +1,2 @@ +declared_name reference_name +1 chr1 diff --git a/examples/reference/fixtures/genes.gff3 b/examples/reference/fixtures/genes.gff3 new file mode 100644 index 0000000..a32cd6b --- /dev/null +++ b/examples/reference/fixtures/genes.gff3 @@ -0,0 +1,3 @@ +##gff-version 3 +##sequence-region chr1 1 4 +chr1 FastaGuard gene 1 4 . + . ID=gene1 diff --git a/examples/reference/fixtures/header-only.dict b/examples/reference/fixtures/header-only.dict new file mode 100644 index 0000000..195f6cc --- /dev/null +++ b/examples/reference/fixtures/header-only.dict @@ -0,0 +1,2 @@ +@HD VN:1.6 +@SQ SN:chr1 LN:4 diff --git a/examples/reference/fixtures/invalid.fa b/examples/reference/fixtures/invalid.fa new file mode 100644 index 0000000..ee0cd6b --- /dev/null +++ b/examples/reference/fixtures/invalid.fa @@ -0,0 +1,2 @@ +>chr1 +ACGTX diff --git a/examples/reference/fixtures/reads.sam b/examples/reference/fixtures/reads.sam new file mode 100644 index 0000000..a147cb8 --- /dev/null +++ b/examples/reference/fixtures/reads.sam @@ -0,0 +1,2 @@ +@HD VN:1.6 +@SQ SN:chr1 LN:4 M5:f1f8f4bf413b16ad135722aa4591043e diff --git a/examples/reference/fixtures/reference.fa.fai b/examples/reference/fixtures/reference.fa.fai new file mode 100644 index 0000000..45750de --- /dev/null +++ b/examples/reference/fixtures/reference.fa.fai @@ -0,0 +1 @@ +chr1 4 6 4 5 diff --git a/examples/reference/fixtures/variants/a/sites.vcf b/examples/reference/fixtures/variants/a/sites.vcf new file mode 100644 index 0000000..12d2018 --- /dev/null +++ b/examples/reference/fixtures/variants/a/sites.vcf @@ -0,0 +1,3 @@ +##fileformat=VCFv4.3 +##contig= +#CHROM POS ID REF ALT QUAL FILTER INFO diff --git a/examples/reference/fixtures/variants/b/sites.vcf b/examples/reference/fixtures/variants/b/sites.vcf new file mode 100644 index 0000000..12d2018 --- /dev/null +++ b/examples/reference/fixtures/variants/b/sites.vcf @@ -0,0 +1,3 @@ +##fileformat=VCFv4.3 +##contig= +#CHROM POS ID REF ALT QUAL FILTER INFO diff --git a/examples/reference/fixtures/workflow-cases.json b/examples/reference/fixtures/workflow-cases.json new file mode 100644 index 0000000..4405a29 --- /dev/null +++ b/examples/reference/fixtures/workflow-cases.json @@ -0,0 +1,81 @@ +{ + "cases": [ + { + "id": "exact", + "fai": "reference.fa.fai", + "dict": "reference.dict", + "policy": "coordinate", + "require": ["fai", "dict"], + "write_lock": true, + "reportable": true, + "can_continue": true + }, + { + "id": "header-coordinate", + "dict": "header-only.dict", + "policy": "coordinate", + "require": ["dict"], + "reportable": true, + "can_continue": true + }, + { + "id": "header-strict", + "dict": "header-only.dict", + "policy": "strict", + "require": ["dict"], + "reportable": true, + "can_continue": false + }, + { + "id": "length-coordinate", + "dict": "copied-length-mismatch.dict", + "policy": "coordinate", + "require": ["dict"], + "reportable": true, + "can_continue": false + }, + { + "id": "length-advisory", + "dict": "copied-length-mismatch.dict", + "policy": "advisory", + "require": ["dict"], + "reportable": true, + "can_continue": true + }, + { + "id": "missing-required", + "policy": "coordinate", + "require": ["dict"], + "reportable": true, + "can_continue": false + }, + { + "id": "invalid-canonical", + "fasta": "invalid.fa", + "policy": "advisory", + "reportable": true, + "can_continue": false + }, + { + "id": "unreadable-supplied", + "dict": "missing.dict", + "policy": "coordinate", + "require": ["dict"], + "reportable": false, + "can_continue": false + } + ], + "repeated": { + "id": "repeated", + "fai": "aliased.fa.fai", + "dict": "reference.dict", + "alias_map": "aliases.tsv", + "alignments": ["reads.sam"], + "variants": ["variants/a/sites.vcf", "variants/b/sites.vcf"], + "annotations": ["genes.gff3"], + "policy": "coordinate", + "require": ["fai", "dict", "alignment", "variants", "annotation"], + "reportable": true, + "can_continue": true + } +} diff --git a/examples/reference/nextflow/main.nf b/examples/reference/nextflow/main.nf new file mode 100644 index 0000000..4a61a3d --- /dev/null +++ b/examples/reference/nextflow/main.nf @@ -0,0 +1,256 @@ +nextflow.enable.dsl = 2 + +def shellQuote(value) { + "'" + value.toString().replace("'", "'\"'\"'") + "'" +} + +def parameter(key) { + params.containsKey(key) ? params.get(key) : null +} + +def defaulted(value, fallback) { + value == null ? fallback : value +} + +def asList(value) { + value ? (value instanceof List ? value : [value]) : [] +} + +def optionalPath(value, key) { + if (value == null) return [] + if (!(value instanceof String) || !value) error "${key} must be a path when supplied" + file(value, checkIfExists: true) +} + +def fileList(value, key) { + def values = defaulted(value, []) + if (!(values instanceof List) || !values.every { item -> item instanceof String && item }) { + error "${key} must be a list of paths" + } + values.collect { item -> file(item, checkIfExists: true) } +} + +def resolveBinary(value) { + def location = value.contains('/') ? value : System.getenv('PATH') + .split(java.io.File.pathSeparator) + .collect { directory -> java.nio.file.Paths.get(directory, value) } + .find { candidate -> java.nio.file.Files.isExecutable(candidate) && java.nio.file.Files.isRegularFile(candidate) } + if (location == null) error "executable not found on PATH: ${value}" + def path = file(location, checkIfExists: true) + if (!java.nio.file.Files.isExecutable(java.nio.file.Paths.get(path.toString()))) { + error "binary is not executable: ${value}" + } + path +} + +def fileDigest(path) { + def digest = java.security.MessageDigest.getInstance('SHA-256') + java.nio.file.Files.newInputStream(java.nio.file.Paths.get(path.toString())).withCloseable { input -> + def hashing = new java.security.DigestInputStream(input, digest) + hashing.transferTo(java.io.OutputStream.nullOutputStream()) + } + digest.digest().encodeHex().toString() +} + +def inputSignature(files, settings) { + def entries = files.collectEntries { kind, paths -> + [(kind): asList(paths).collect { path -> + [path: path.toString(), sha256: fileDigest(path)] + }] + } + def payload = groovy.json.JsonOutput.toJson([files: entries, settings: settings]) + java.security.MessageDigest.getInstance('SHA-256') + .digest(payload.getBytes('UTF-8')).encodeHex().toString() +} + +process REFERENCE_REPORT { + cache 'deep' + + input: + path fasta, stageAs: 'canonical/*' + path fai, stageAs: 'fai/*' + path dictionary, stageAs: 'dict/*' + path aliases, stageAs: 'aliases/*' + path alignments, stageAs: 'alignments??/*' + path variants, stageAs: 'variants??/*' + path annotations, stageAs: 'annotations??/*' + path binary, stageAs: 'fastaguard-bin' + val policy + val required + val write_lock + + output: + path 'bundle/reference.fastaguard.html', emit: html + path 'bundle/reference.fastaguard.json', emit: json + path 'bundle/reference.fastaguard.tsv', emit: tsv + path 'bundle/reference.fastaguard_mqc.json', emit: multiqc + path 'bundle/reference.fastaguard.lock.json', optional: true, emit: lock + path 'bundle', emit: bundle + + script: + def args = ['./fastaguard-bin', 'reference', fasta] + if (fai) args.addAll(['--fai', fai]) + if (dictionary) args.addAll(['--dict', dictionary]) + if (aliases) args.addAll(['--alias-map', aliases]) + asList(alignments).each { path -> args.addAll(['--alignment', path]) } + asList(variants).each { path -> args.addAll(['--variants', path]) } + asList(annotations).each { path -> args.addAll(['--annotation', path]) } + args.addAll(['--policy', policy, '--outdir', 'bundle', '--prefix', 'reference']) + if (required) args.addAll(['--require', required.join(',')]) + if (write_lock) args.add('--write-lock') + """ + ${args.collect { arg -> shellQuote(arg) }.join(' ')} + """ +} + +process PERSIST_REPORTS { + cache false + + input: + path bundle + val destination + val signature + + output: + path 'persist.receipt', emit: receipt + + script: + def parent = java.nio.file.Paths.get(destination).parent + """ + mkdir -p ${shellQuote(parent)} + if [ -e ${shellQuote(destination)} ]; then + [ -d ${shellQuote(destination)} ] || exit 3 + [ -f ${shellQuote(destination)}/.reference-inputs.sha256 ] || exit 3 + [ "\$(cat ${shellQuote(destination)}/.reference-inputs.sha256)" = ${shellQuote(signature)} ] || { + printf 'Run ID already belongs to different inputs.\\n' >&2 + exit 3 + } + for report in bundle/*; do + name=\$(basename "\$report") + cmp -s "\$report" ${shellQuote(destination)}/"\$name" || { + printf 'Existing report differs: %s\\n' "\$name" >&2 + exit 3 + } + done + if [ -e ${shellQuote(destination)}/reference.fastaguard.lock.json ] && [ ! -e bundle/reference.fastaguard.lock.json ]; then + printf 'Existing lockfile differs from this run.\\n' >&2 + exit 3 + fi + else + temporary=\$(mktemp -d ${shellQuote(destination + '.tmp.XXXXXXXX')}) + trap 'rm -rf "\$temporary"' EXIT + cp -R bundle/. "\$temporary"/ + printf '%s\\n' ${shellQuote(signature)} > "\$temporary"/.reference-inputs.sha256 + mv "\$temporary" ${shellQuote(destination)} + trap - EXIT + fi + printf '%s\\n' ${shellQuote(destination)} > persist.receipt + """ +} + +process REFERENCE_GATE { + cache false + + input: + path receipt + path report + path helper + + output: + path 'reference.accepted.sha256', emit: accepted + + script: + """ + python3 ${shellQuote(helper)} ${shellQuote(report)} + python3 -c 'import hashlib, pathlib, sys; print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())' ${shellQuote(report)} > reference.accepted.sha256 + """ +} + +process DOWNSTREAM { + cache false + + input: + path accepted + path report + val destination + val run_id + + output: + path 'downstream.started', emit: marker + + script: + """ + python3 -c 'import hashlib, json, pathlib, sys; report = pathlib.Path(sys.argv[1]).read_bytes(); marker = pathlib.Path(sys.argv[2]).read_text().strip(); assert marker == hashlib.sha256(report).hexdigest(); assert json.loads(report)["gate"]["can_continue"] is True' ${shellQuote(report)} ${shellQuote(accepted)} + printf '%s\\n' ${shellQuote(run_id)} > downstream.started + cp downstream.started ${shellQuote(destination)}/downstream.started + """ +} + +workflow { + def fasta = parameter('fasta') + def output_dir = parameter('output_dir') + if (!(fasta instanceof String) || !fasta) error 'fasta must be a non-empty path' + if (!(output_dir instanceof String) || !output_dir) error 'output_dir must be a non-empty path' + + def run_id = defaulted(parameter('run_id'), 'reference') + if (!(run_id instanceof String) || !(run_id ==~ /[A-Za-z0-9][A-Za-z0-9_-]*/)) { + error 'run_id must contain only letters, digits, underscores or hyphens' + } + def mode = defaulted(parameter('mode'), 'report') + def policy = defaulted(parameter('policy'), 'coordinate') + def required = defaulted(parameter('require'), []) + def write_lock = defaulted(parameter('write_lock'), false) + def binary_value = defaulted(parameter('binary'), 'fastaguard') + if (!(mode in ['report', 'enforce'])) error 'mode must be report or enforce' + if (!(policy in ['strict', 'coordinate', 'advisory'])) error 'policy must be strict, coordinate or advisory' + if (!(required instanceof List) || !required.every { kind -> kind in ['fai', 'dict', 'alignment', 'variants', 'annotation'] }) { + error 'require must list supported companion kinds' + } + if (!(write_lock instanceof Boolean)) error 'write_lock must be boolean' + if (!(binary_value instanceof String) || !binary_value) error 'binary must be a non-empty executable path or command' + + def binary_file = resolveBinary(binary_value) + def fasta_file = file(fasta, checkIfExists: true) + def fai_file = optionalPath(parameter('fai'), 'fai') + def dict_file = optionalPath(parameter('dict'), 'dict') + def alias_file = optionalPath(parameter('alias_map'), 'alias_map') + def alignment_files = fileList(parameter('alignments'), 'alignments') + def variant_files = fileList(parameter('variants'), 'variants') + def annotation_files = fileList(parameter('annotations'), 'annotations') + def signature = inputSignature( + [fasta: fasta_file, fai: fai_file, dict: dict_file, aliases: alias_file, + alignments: alignment_files, variants: variant_files, + annotations: annotation_files, binary: binary_file], + [mode: mode, policy: policy, require: required, write_lock: write_lock], + ) + def destination = java.nio.file.Paths.get(output_dir).toAbsolutePath().normalize().resolve(run_id).toString() + def helper = file("${projectDir}/../../workflows/check_fastaguard_gate.py", checkIfExists: true) + + REFERENCE_REPORT( + channel.value(fasta_file), + channel.value(fai_file), + channel.value(dict_file), + channel.value(alias_file), + channel.value(alignment_files), + channel.value(variant_files), + channel.value(annotation_files), + channel.value(binary_file), + channel.value(policy), + channel.value(required), + channel.value(write_lock), + ) + PERSIST_REPORTS(REFERENCE_REPORT.out.bundle, channel.value(destination), channel.value(signature)) + if (mode == 'enforce') { + REFERENCE_GATE( + PERSIST_REPORTS.out.receipt, + REFERENCE_REPORT.out.json, + channel.value(helper), + ) + DOWNSTREAM( + REFERENCE_GATE.out.accepted, + REFERENCE_REPORT.out.json, + channel.value(destination), + channel.value(run_id), + ) + } +} diff --git a/examples/reference/nextflow/params.json b/examples/reference/nextflow/params.json new file mode 100644 index 0000000..7b8ee35 --- /dev/null +++ b/examples/reference/nextflow/params.json @@ -0,0 +1,11 @@ +{ + "fasta": "examples/reference/fixtures/reference.fa", + "fai": "examples/reference/fixtures/reference.fa.fai", + "dict": "examples/reference/fixtures/reference.dict", + "output_dir": "target/reference-nextflow", + "run_id": "exact", + "mode": "enforce", + "policy": "coordinate", + "require": ["fai", "dict"], + "write_lock": true +} diff --git a/examples/reference/snakemake/Snakefile b/examples/reference/snakemake/Snakefile new file mode 100644 index 0000000..f8dec97 --- /dev/null +++ b/examples/reference/snakemake/Snakefile @@ -0,0 +1,221 @@ +import hashlib +import json +import re +import shutil +from pathlib import Path + + +def required(key): + value = config.get(key) + if not isinstance(value, str) or not value: + raise ValueError(f"{key} must be a non-empty path") + return str(Path(value).expanduser().resolve()) + + +def optional(key): + value = config.get(key) + if value is None: + return [] + if not isinstance(value, str) or not value: + raise ValueError(f"{key} must be a path when supplied") + return [str(Path(value).expanduser().resolve())] + + +def paths(key): + values = config.get(key, []) + if not isinstance(values, list) or any( + not isinstance(value, str) or not value for value in values + ): + raise ValueError(f"{key} must be a list of paths") + return [str(Path(value).expanduser().resolve()) for value in values] + + +FASTA = required("fasta") +FAI = optional("fai") +DICT = optional("dict") +ALIASES = optional("alias_map") +ALIGNMENTS = paths("alignments") +VARIANTS = paths("variants") +ANNOTATIONS = paths("annotations") +OUTPUT_ROOT = Path(required("output_dir")) +RUN_ID = config.get("run_id", "reference") +if not isinstance(RUN_ID, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", RUN_ID): + raise ValueError("run_id must contain only letters, digits, underscores or hyphens") +RUN_DIR = OUTPUT_ROOT / RUN_ID +MODE = config.get("mode", "report") +POLICY = config.get("policy", "coordinate") +REQUIRE = config.get("require", []) +WRITE_LOCK = config.get("write_lock", False) +BINARY = config.get("binary", "fastaguard") +if MODE not in ("report", "enforce"): + raise ValueError("mode must be report or enforce") +if POLICY not in ("strict", "coordinate", "advisory"): + raise ValueError("policy must be strict, coordinate or advisory") +if not isinstance(REQUIRE, list) or any( + kind not in ("fai", "dict", "alignment", "variants", "annotation") + for kind in REQUIRE +): + raise ValueError("require must list supported companion kinds") +if not isinstance(WRITE_LOCK, bool): + raise ValueError("write_lock must be boolean") +if not isinstance(BINARY, str) or not BINARY: + raise ValueError("binary must be a non-empty executable path or command") + +HELPER = Path(workflow.basedir).resolve().parents[1] / "workflows/check_fastaguard_gate.py" +REPORTS = { + "html": str(RUN_DIR / "reference.fastaguard.html"), + "json": str(RUN_DIR / "reference.fastaguard.json"), + "tsv": str(RUN_DIR / "reference.fastaguard.tsv"), + "multiqc": str(RUN_DIR / "reference.fastaguard_mqc.json"), +} +LOCK = str(RUN_DIR / "reference.fastaguard.lock.json") +STAGING = RUN_DIR / ".staging" +STAGED_REPORTS = { + name: str(STAGING / Path(path).name) for name, path in REPORTS.items() +} +REPORT_OUTPUTS = dict(STAGED_REPORTS) +if WRITE_LOCK: + REPORT_OUTPUTS["lock"] = str(STAGING / Path(LOCK).name) +SIGNATURE_FILE = RUN_DIR / ".reference-inputs.sha256" +REPORT_DIGESTS = RUN_DIR / ".reference-report-digests.json" +PUBLISHED = str(RUN_DIR / ".reference-published") +ACCEPTED = str(RUN_DIR / "reference.accepted.sha256") +DOWNSTREAM = str(RUN_DIR / "downstream.started") + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +input_files = [FASTA, *FAI, *DICT, *ALIASES, *ALIGNMENTS, *VARIANTS, *ANNOTATIONS] +resolved_binary = shutil.which(BINARY) +if resolved_binary: + input_files.append(str(Path(resolved_binary).resolve())) +signature_payload = { + "files": [(path, sha256_file(path)) for path in input_files], + "policy": POLICY, + "require": REQUIRE, + "write_lock": WRITE_LOCK, + "mode": MODE, + "binary": BINARY, +} +RUN_SIGNATURE = hashlib.sha256( + json.dumps(signature_payload, sort_keys=True, separators=(",", ":")).encode() +).hexdigest() +if RUN_DIR.exists(): + if not RUN_DIR.is_dir(): + raise ValueError(f"output path is not a directory: {RUN_DIR}") + allowed = { + STAGING.name, SIGNATURE_FILE.name, REPORT_DIGESTS.name, + Path(PUBLISHED).name, + Path(ACCEPTED).name, + Path(DOWNSTREAM).name, Path(LOCK).name, + ".reference-report.log", ".reference-publish.log", + ".reference-gate.log", ".downstream.log", + *(Path(path).name for path in REPORTS.values()), + } + if any(path.name not in allowed for path in RUN_DIR.iterdir()): + raise ValueError(f"occupied run_id contains unexpected files: {RUN_DIR}") + if SIGNATURE_FILE.is_file(): + if SIGNATURE_FILE.read_text().strip() != RUN_SIGNATURE: + raise ValueError(f"run_id already belongs to different inputs: {RUN_ID}") + if not REPORT_DIGESTS.is_file(): + raise ValueError(f"published report digests are missing: {RUN_DIR}") + expected = json.loads(REPORT_DIGESTS.read_text()) + published = dict(REPORTS) + if WRITE_LOCK: + published["lock"] = LOCK + if ( + set(expected) != set(published) + or any( + not Path(path).is_file() or sha256_file(path) != expected[name] + for name, path in published.items() + ) + ): + raise ValueError(f"published reports are missing or modified: {RUN_DIR}") + elif any(Path(path).exists() for path in (PUBLISHED, ACCEPTED, DOWNSTREAM)): + raise ValueError(f"occupied run_id has no input signature: {RUN_DIR}") + + +rule all: + input: + PUBLISHED if MODE == "report" else DOWNSTREAM + + +rule reference_report: + input: + fasta=FASTA, + fai=FAI, + dict=DICT, + alias_map=ALIASES, + alignments=ALIGNMENTS, + variants=VARIANTS, + annotations=ANNOTATIONS + output: + **REPORT_OUTPUTS + params: + binary=BINARY, + policy=POLICY, + required=REQUIRE, + write_lock=WRITE_LOCK + conda: + "../../snakemake/wrapper/environment.yaml" + log: + str(RUN_DIR / ".reference-report.log") + script: + "scripts/report.py" + + +rule publish_reports: + input: + **REPORT_OUTPUTS + output: + complete=PUBLISHED + params: + reports=REPORTS, + lock=LOCK if WRITE_LOCK else None, + report_digests=str(REPORT_DIGESTS), + signature_path=str(SIGNATURE_FILE), + signature=RUN_SIGNATURE + conda: + "../../snakemake/wrapper/environment.yaml" + log: + str(RUN_DIR / ".reference-publish.log") + script: + "scripts/publish.py" + + +rule reference_gate: + input: + published=PUBLISHED, + helper=str(HELPER) + output: + accepted=ACCEPTED + params: + report=REPORTS["json"] + conda: + "../../snakemake/wrapper/environment.yaml" + log: + str(RUN_DIR / ".reference-gate.log") + script: + "scripts/gate.py" + + +rule downstream: + input: + accepted=ACCEPTED + output: + marker=DOWNSTREAM + params: + run_id=RUN_ID, + report=REPORTS["json"] + conda: + "../../snakemake/wrapper/environment.yaml" + log: + str(RUN_DIR / ".downstream.log") + script: + "scripts/downstream.py" diff --git a/examples/reference/snakemake/config.json b/examples/reference/snakemake/config.json new file mode 100644 index 0000000..5fdbbe0 --- /dev/null +++ b/examples/reference/snakemake/config.json @@ -0,0 +1,11 @@ +{ + "fasta": "examples/reference/fixtures/reference.fa", + "fai": "examples/reference/fixtures/reference.fa.fai", + "dict": "examples/reference/fixtures/reference.dict", + "output_dir": "target/reference-snakemake", + "run_id": "exact", + "mode": "enforce", + "policy": "coordinate", + "require": ["fai", "dict"], + "write_lock": true +} diff --git a/examples/reference/snakemake/scripts/downstream.py b/examples/reference/snakemake/scripts/downstream.py new file mode 100644 index 0000000..2dd4af7 --- /dev/null +++ b/examples/reference/snakemake/scripts/downstream.py @@ -0,0 +1,14 @@ +import hashlib +import json +from pathlib import Path + + +report_bytes = Path(snakemake.params.report).read_bytes() +digest = hashlib.sha256(report_bytes).hexdigest() +if Path(snakemake.input.accepted).read_text().strip() != digest: + raise RuntimeError("acceptance marker does not match the current report") +report = json.loads(report_bytes) +if report.get("gate", {}).get("can_continue") is not True: + raise RuntimeError("current reference report does not permit downstream work") +Path(snakemake.output.marker).write_text(f"{snakemake.params.run_id}\n") +Path(snakemake.log[0]).write_text("Reference accepted for downstream work.\n") diff --git a/examples/reference/snakemake/scripts/gate.py b/examples/reference/snakemake/scripts/gate.py new file mode 100644 index 0000000..09d199e --- /dev/null +++ b/examples/reference/snakemake/scripts/gate.py @@ -0,0 +1,20 @@ +import hashlib +import subprocess +import sys +from pathlib import Path + + +Path(snakemake.output.accepted).unlink(missing_ok=True) +result = subprocess.run( + [sys.executable, str(snakemake.input.helper), str(snakemake.params.report)], + text=True, + capture_output=True, +) +message = result.stdout + result.stderr +Path(snakemake.log[0]).write_text(message) +print(message, end="") +if result.returncode: + raise RuntimeError("reference gate failed; see gate log") +Path(snakemake.output.accepted).write_text( + hashlib.sha256(Path(snakemake.params.report).read_bytes()).hexdigest() + "\n" +) diff --git a/examples/reference/snakemake/scripts/publish.py b/examples/reference/snakemake/scripts/publish.py new file mode 100644 index 0000000..6602073 --- /dev/null +++ b/examples/reference/snakemake/scripts/publish.py @@ -0,0 +1,63 @@ +import filecmp +import hashlib +import json +import os +import shutil +from pathlib import Path +from tempfile import NamedTemporaryFile + + +reports = dict(snakemake.params.reports) +if snakemake.params.lock: + reports["lock"] = snakemake.params.lock + +for name, destination in reports.items(): + source = Path(getattr(snakemake.input, name)) + target = Path(destination) + if target.exists() and not filecmp.cmp(source, target, shallow=False): + raise RuntimeError(f"existing report differs: {target}") + +for name, destination in reports.items(): + source = Path(getattr(snakemake.input, name)) + target = Path(destination) + if target.exists(): + continue + with NamedTemporaryFile(dir=target.parent, prefix=".reference-copy-", delete=False) as temp: + temporary = Path(temp.name) + with source.open("rb") as handle: + shutil.copyfileobj(handle, temp) + try: + os.replace(temporary, target) + finally: + temporary.unlink(missing_ok=True) + +digests = {} +for name, destination in reports.items(): + digest = hashlib.sha256() + with Path(destination).open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + digests[name] = digest.hexdigest() +with NamedTemporaryFile( + mode="w", dir=Path(snakemake.params.report_digests).parent, + prefix=".reference-digests-", delete=False, +) as temp: + temporary = Path(temp.name) + json.dump(digests, temp, sort_keys=True) +try: + os.replace(temporary, snakemake.params.report_digests) +finally: + temporary.unlink(missing_ok=True) + +with NamedTemporaryFile( + mode="w", dir=Path(snakemake.params.signature_path).parent, + prefix=".reference-signature-", delete=False, +) as temp: + temporary = Path(temp.name) + temp.write(snakemake.params.signature + "\n") +try: + os.replace(temporary, snakemake.params.signature_path) +finally: + temporary.unlink(missing_ok=True) +Path(snakemake.output.complete).write_text(snakemake.params.signature + "\n") +Path(snakemake.log[0]).write_text("Reference reports published.\n") diff --git a/examples/reference/snakemake/scripts/report.py b/examples/reference/snakemake/scripts/report.py new file mode 100644 index 0000000..6d82f09 --- /dev/null +++ b/examples/reference/snakemake/scripts/report.py @@ -0,0 +1,29 @@ +import subprocess +from pathlib import Path + + +command = [snakemake.params.binary, "reference", str(snakemake.input.fasta)] +for name, flag in ( + ("fai", "--fai"), ("dict", "--dict"), ("alias_map", "--alias-map") +): + values = getattr(snakemake.input, name) + if values: + command.extend([flag, str(values[0])]) +for name, flag in ( + ("alignments", "--alignment"), + ("variants", "--variants"), + ("annotations", "--annotation"), +): + for path in getattr(snakemake.input, name): + command.extend([flag, str(path)]) +command.extend(["--policy", snakemake.params.policy]) +if snakemake.params.required: + command.extend(["--require", ",".join(snakemake.params.required)]) +command.extend(["--outdir", str(Path(snakemake.output.json).parent), "--prefix", "reference"]) +if snakemake.params.write_lock: + command.append("--write-lock") + +log_path = Path(snakemake.log[0]) +log_path.parent.mkdir(parents=True, exist_ok=True) +with log_path.open("w") as log: + subprocess.run(command, stdout=log, stderr=subprocess.STDOUT, check=True) diff --git a/tests/python/test_reference_workflows.py b/tests/python/test_reference_workflows.py new file mode 100644 index 0000000..b30b273 --- /dev/null +++ b/tests/python/test_reference_workflows.py @@ -0,0 +1,496 @@ +import json +import os +import shlex +import shutil +import subprocess +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +import jsonschema + + +ROOT = Path(__file__).resolve().parents[2] +FIXTURES = ROOT / "examples/reference/fixtures" +MANIFEST = FIXTURES / "workflow-cases.json" +NEXTFLOW = ROOT / "examples/reference/nextflow/main.nf" +SNAKEMAKE = ROOT / "examples/reference/snakemake/Snakefile" + + +class ReferenceWorkflowTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.cases = json.loads(MANIFEST.read_text())["cases"] + cls.binary = Path( + os.environ.get("FASTAGUARD_BINARY", ROOT / "target/debug/fastaguard") + ).resolve(strict=True) + cls.schema = json.loads( + subprocess.check_output([str(cls.binary), "--schema"], text=True) + ) + + def file(self, name): + return str(FIXTURES / name) + + def config_for(self, case, root, mode="enforce"): + config = { + "fasta": self.file(case.get("fasta", "reference.fa")), + "output_dir": str(root / "reports with spaces"), + "run_id": case["id"], + "mode": mode, + "policy": case.get("policy", "coordinate"), + "require": case.get("require", []), + "write_lock": case.get("write_lock", False), + "binary": str(self.binary), + } + for key in ("fai", "dict", "alias_map"): + if key in case: + config[key] = self.file(case[key]) + for key in ("alignments", "variants", "annotations"): + config[key] = [self.file(value) for value in case.get(key, [])] + return config + + def cli_args(self, config, json_path): + args = [str(self.binary), "reference", config["fasta"]] + for key, flag in ( + ("fai", "--fai"), ("dict", "--dict"), + ("alias_map", "--alias-map"), + ): + if config.get(key): + args.extend([flag, config[key]]) + for key, flag in ( + ("alignments", "--alignment"), + ("variants", "--variants"), + ("annotations", "--annotation"), + ): + for value in config.get(key, []): + args.extend([flag, value]) + args.extend(["--policy", config["policy"]]) + if config["require"]: + args.extend(["--require", ",".join(config["require"])]) + args.extend(["--format", "json", "--json", str(json_path)]) + return args + + def assert_reports(self, directory, expected, diagnostic=""): + files = [ + directory / f"reference.fastaguard.{suffix}" + for suffix in ("html", "json", "tsv") + ] + [directory / "reference.fastaguard_mqc.json"] + self.assertTrue( + all(path.is_file() and path.stat().st_size for path in files), + diagnostic, + ) + report = json.loads(files[1].read_text()) + jsonschema.validate(report, self.schema) + self.assertIs(report["gate"]["can_continue"], expected) + return report + + def test_manifest_matches_the_published_cli(self): + self.assertEqual(len(self.cases), 8) + with TemporaryDirectory() as temp: + root = Path(temp) + for case in self.cases: + with self.subTest(case=case["id"]): + config = self.config_for(case, root) + report_path = root / f"{case['id']}.json" + result = subprocess.run( + self.cli_args(config, report_path), + text=True, capture_output=True, + ) + if case["reportable"]: + self.assertEqual(result.returncode, 0, result.stderr) + report = json.loads(report_path.read_text()) + jsonschema.validate(report, self.schema) + self.assertIs( + report["gate"]["can_continue"], case["can_continue"] + ) + else: + self.assertNotEqual(result.returncode, 0) + self.assertFalse(report_path.exists()) + + def nextflow_command(self): + jar = os.environ.get("FASTAGUARD_NEXTFLOW_JAR") + if jar: + return [ + os.environ.get("FASTAGUARD_JAVA", "java"), "-jar", jar, + ] + executable = shutil.which("nextflow") + if executable: + return [executable] + self.skipTest("Nextflow executable unavailable") + + def snakemake_command(self): + executable = os.environ.get("FASTAGUARD_SNAKEMAKE") or shutil.which( + "snakemake" + ) + if executable: + return [executable] + self.skipTest("Snakemake executable unavailable") + + def run_engine(self, engine, config, root, resume=False, extra_args=(), env=None): + params_file = root / "parameters.json" + params_file.write_text(json.dumps(config)) + if engine == "nextflow": + command = self.nextflow_command() + [ + "run", str(NEXTFLOW), "-offline", "-ansi-log", "false", + "-work-dir", str(root / "nextflow-work"), + "-params-file", str(params_file), + ] + if resume: + command.append("-resume") + command.extend(extra_args) + cwd = root + else: + workspace = root / "snakemake-work" + workspace.mkdir(exist_ok=True) + command = self.snakemake_command() + [ + "--snakefile", str(SNAKEMAKE), "--cores", "1", + "--directory", str(workspace), + "--configfile", str(params_file), "--quiet", + ] + command.extend(extra_args) + cwd = root + return subprocess.run(command, cwd=cwd, text=True, capture_output=True, env=env) + + def wrapper(self, root, name="fastaguard-wrapper"): + wrapper = root / name + wrapper.write_text( + "#!/bin/sh\n" + "if [ \"${FASTAGUARD_TEST_FAIL:-0}\" = 1 ]; then exit 7; fi\n" + f"exec {shlex.quote(str(self.binary))} \"$@\"\n" + ) + wrapper.chmod(0o755) + return wrapper + + def check_engine_matrix(self, engine): + selected = os.environ.get("FASTAGUARD_WORKFLOW_CASE") + for case in self.cases: + if selected and case["id"] != selected: + continue + with self.subTest(engine=engine, case=case["id"]): + with TemporaryDirectory(prefix=f"{engine} reference ") as temp: + root = Path(temp) + config = self.config_for(case, root) + result = self.run_engine(engine, config, root) + directory = Path(config["output_dir"]) / config["run_id"] + marker = directory / "downstream.started" + if case["reportable"]: + report = self.assert_reports( + directory, case["can_continue"], + result.stdout + result.stderr, + ) + self.assertEqual(report["report_type"], "reference") + self.assertEqual( + (directory / "reference.fastaguard.lock.json").exists(), + case.get("write_lock", False), + ) + self.assertEqual( + result.returncode == 0, case["can_continue"], + result.stdout + result.stderr, + ) + self.assertEqual(marker.exists(), case["can_continue"]) + if not case["can_continue"]: + self.assertIn("gate.can_continue=false", result.stdout + result.stderr) + else: + self.assertNotEqual(result.returncode, 0) + self.assertFalse((directory / "reference.fastaguard.json").exists()) + self.assertFalse(marker.exists()) + + def test_nextflow_matrix(self): + self.check_engine_matrix("nextflow") + + def test_snakemake_matrix(self): + self.check_engine_matrix("snakemake") + + def test_report_only_preserves_blocked_evidence(self): + chosen = {case["id"]: case for case in self.cases} + for engine in ("nextflow", "snakemake"): + for name in ("exact", "length-coordinate"): + with self.subTest(engine=engine, case=name): + with TemporaryDirectory() as temp: + root = Path(temp) + case = chosen[name] + config = self.config_for(case, root, mode="report") + result = self.run_engine(engine, config, root) + directory = Path(config["output_dir"]) / config["run_id"] + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assert_reports(directory, case["can_continue"]) + self.assertEqual( + (directory / "reference.fastaguard.lock.json").exists(), + case.get("write_lock", False), + ) + self.assertFalse((directory / "downstream.started").exists()) + self.assertFalse((directory / "reference.accepted.sha256").exists()) + + def test_unsafe_run_ids_fail_before_reporting(self): + case = self.cases[0] + for engine in ("nextflow", "snakemake"): + with self.subTest(engine=engine): + with TemporaryDirectory() as temp: + root = Path(temp) + config = self.config_for(case, root) + config["run_id"] = "../escape" + result = self.run_engine(engine, config, root) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((root / "escape").exists()) + self.assertFalse((Path(config["output_dir"]) / "escape").exists()) + + def test_repeated_companions_and_aliases(self): + case = json.loads(MANIFEST.read_text())["repeated"] + for engine in ("nextflow", "snakemake"): + with self.subTest(engine=engine): + with TemporaryDirectory(prefix="repeated reference ") as temp: + root = Path(temp) + config = self.config_for(case, root) + executable = root / "tool with spaces" + executable.symlink_to(self.binary) + config["binary"] = str(executable) + result = self.run_engine(engine, config, root) + directory = Path(config["output_dir"]) / config["run_id"] + report = self.assert_reports(directory, True, result.stdout + result.stderr) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + kinds = [item["kind"] for item in report["comparisons"]] + for kind in ("fai", "dict", "alignment", "annotation"): + self.assertIn(kind, kinds) + self.assertEqual(kinds.count("variants"), 2) + self.assertTrue((directory / "downstream.started").exists()) + + def test_changed_inputs_do_not_reuse_an_old_acceptance(self): + original = {case["id"]: case for case in self.cases}["exact"] + case = dict(original, write_lock=False) + for engine in ("nextflow", "snakemake"): + with self.subTest(engine=engine): + with TemporaryDirectory() as temp: + root = Path(temp) + dictionary = root / "reference copy.dict" + dictionary.write_bytes((FIXTURES / "reference.dict").read_bytes()) + config = self.config_for(case, root) + config["dict"] = str(dictionary) + first = self.run_engine(engine, config, root) + initial_dir = Path(config["output_dir"]) / config["run_id"] + self.assertEqual(first.returncode, 0, first.stdout + first.stderr) + self.assert_reports(initial_dir, True) + original_report = ( + initial_dir / "reference.fastaguard.json" + ).read_bytes() + original_marker = (initial_dir / "downstream.started").read_bytes() + + dictionary.write_bytes( + (FIXTURES / "copied-length-mismatch.dict").read_bytes() + ) + config["run_id"] = "changed-input" + second = self.run_engine(engine, config, root, resume=True) + changed_dir = Path(config["output_dir"]) / config["run_id"] + self.assertNotEqual(second.returncode, 0) + self.assert_reports( + changed_dir, False, second.stdout + second.stderr + ) + self.assertFalse((changed_dir / "downstream.started").exists()) + self.assertEqual( + (initial_dir / "reference.fastaguard.json").read_bytes(), + original_report, + ) + self.assertEqual( + (initial_dir / "downstream.started").read_bytes(), + original_marker, + ) + + def test_same_input_resume_keeps_published_reports(self): + case = {item["id"]: item for item in self.cases}["exact"] + for engine in ("nextflow", "snakemake"): + with self.subTest(engine=engine): + with TemporaryDirectory() as temp: + root = Path(temp) + config = self.config_for(case, root) + first = self.run_engine(engine, config, root) + self.assertEqual(first.returncode, 0, first.stdout + first.stderr) + directory = Path(config["output_dir"]) / config["run_id"] + before = { + path.name: path.read_bytes() + for path in directory.iterdir() if path.is_file() + } + second = self.run_engine(engine, config, root, resume=True) + self.assertEqual(second.returncode, 0, second.stdout + second.stderr) + self.assertEqual( + before, + { + path.name: path.read_bytes() + for path in directory.iterdir() if path.is_file() + }, + ) + + def test_changed_input_cannot_reuse_the_same_run_id(self): + original = {item["id"]: item for item in self.cases}["exact"] + case = dict(original, write_lock=False) + for engine in ("nextflow", "snakemake"): + with self.subTest(engine=engine): + with TemporaryDirectory() as temp: + root = Path(temp) + dictionary = root / "mutable.dict" + dictionary.write_bytes((FIXTURES / "reference.dict").read_bytes()) + config = self.config_for(case, root) + config["dict"] = str(dictionary) + first = self.run_engine(engine, config, root) + self.assertEqual(first.returncode, 0, first.stdout + first.stderr) + directory = Path(config["output_dir"]) / config["run_id"] + before = { + path.name: path.read_bytes() + for path in directory.iterdir() if path.is_file() + } + dictionary.write_bytes( + (FIXTURES / "copied-length-mismatch.dict").read_bytes() + ) + second = self.run_engine(engine, config, root, resume=True) + self.assertNotEqual(second.returncode, 0) + self.assertEqual( + before, + { + path.name: path.read_bytes() + for path in directory.iterdir() if path.is_file() + }, + second.stdout + second.stderr, + ) + + def test_failed_snakemake_rerun_preserves_published_reports(self): + case = {item["id"]: item for item in self.cases}["exact"] + with TemporaryDirectory() as temp: + root = Path(temp) + config = self.config_for(case, root) + config["binary"] = str(self.wrapper(root)) + first = self.run_engine("snakemake", config, root) + self.assertEqual(first.returncode, 0, first.stdout + first.stderr) + directory = Path(config["output_dir"]) / config["run_id"] + before = { + path.name: path.read_bytes() + for path in directory.glob("reference.fastaguard*") + } + marker = (directory / "downstream.started").read_bytes() + env = dict(os.environ, FASTAGUARD_TEST_FAIL="1") + failed = self.run_engine( + "snakemake", config, root, + extra_args=("--forcerun", "reference_report"), env=env, + ) + self.assertNotEqual(failed.returncode, 0) + self.assertEqual( + before, + { + path.name: path.read_bytes() + for path in directory.glob("reference.fastaguard*") + }, + failed.stdout + failed.stderr, + ) + self.assertEqual((directory / "downstream.started").read_bytes(), marker) + retry = self.run_engine("snakemake", config, root) + self.assertEqual(retry.returncode, 0, retry.stdout + retry.stderr) + + def test_snakemake_retries_after_first_cli_failure(self): + case = {item["id"]: item for item in self.cases}["exact"] + with TemporaryDirectory() as temp: + root = Path(temp) + config = self.config_for(case, root) + config["binary"] = str(self.wrapper(root)) + failed = self.run_engine( + "snakemake", config, root, + env=dict(os.environ, FASTAGUARD_TEST_FAIL="1"), + ) + self.assertNotEqual(failed.returncode, 0) + retry = self.run_engine("snakemake", config, root) + self.assertEqual(retry.returncode, 0, retry.stdout + retry.stderr) + self.assert_reports(Path(config["output_dir"]) / config["run_id"], True) + + def test_nextflow_rejects_changed_vcf_records_with_same_declaration(self): + case = json.loads(MANIFEST.read_text())["repeated"] + with TemporaryDirectory() as temp: + root = Path(temp) + config = self.config_for(case, root) + variant = root / "sites.vcf" + variant.write_bytes((FIXTURES / "variants/a/sites.vcf").read_bytes()) + config["variants"][0] = str(variant) + first = self.run_engine("nextflow", config, root) + self.assertEqual(first.returncode, 0, first.stdout + first.stderr) + directory = Path(config["output_dir"]) / config["run_id"] + before = (directory / "reference.fastaguard.json").read_bytes() + variant.write_text(variant.read_text() + "chr1\t1\t.\tA\tC\t.\tPASS\t.\n") + changed = self.run_engine("nextflow", config, root, resume=True) + self.assertNotEqual(changed.returncode, 0, changed.stdout + changed.stderr) + self.assertEqual((directory / "reference.fastaguard.json").read_bytes(), before) + + def test_nextflow_tracks_a_path_resolved_executable(self): + case = {item["id"]: item for item in self.cases}["exact"] + with TemporaryDirectory() as temp: + root = Path(temp) + wrapper = self.wrapper(root, "fastaguard-test") + config = self.config_for(case, root) + config["binary"] = wrapper.name + env = dict(os.environ, PATH=f"{root}:{os.environ['PATH']}") + first = self.run_engine("nextflow", config, root, env=env) + self.assertEqual(first.returncode, 0, first.stdout + first.stderr) + directory = Path(config["output_dir"]) / config["run_id"] + before = (directory / "reference.fastaguard.json").read_bytes() + wrapper.write_text(wrapper.read_text() + "# executable changed\n") + changed = self.run_engine("nextflow", config, root, resume=True, env=env) + self.assertNotEqual(changed.returncode, 0, changed.stdout + changed.stderr) + self.assertEqual((directory / "reference.fastaguard.json").read_bytes(), before) + + def test_changed_mode_requires_a_new_run_id(self): + case = {item["id"]: item for item in self.cases}["exact"] + for engine in ("nextflow", "snakemake"): + with self.subTest(engine=engine): + with TemporaryDirectory() as temp: + root = Path(temp) + config = self.config_for(case, root) + first = self.run_engine(engine, config, root) + self.assertEqual(first.returncode, 0, first.stdout + first.stderr) + config["mode"] = "report" + changed = self.run_engine(engine, config, root, resume=True) + self.assertNotEqual(changed.returncode, 0) + directory = Path(config["output_dir"]) / config["run_id"] + self.assertTrue((directory / "downstream.started").is_file()) + + def test_snakemake_detects_missing_or_modified_published_report(self): + case = {item["id"]: item for item in self.cases}["exact"] + for damage in ("missing", "modified"): + with self.subTest(damage=damage): + with TemporaryDirectory() as temp: + root = Path(temp) + config = self.config_for(case, root) + first = self.run_engine("snakemake", config, root) + self.assertEqual(first.returncode, 0, first.stdout + first.stderr) + directory = Path(config["output_dir"]) / config["run_id"] + report = directory / "reference.fastaguard.tsv" + if damage == "missing": + report.unlink() + else: + report.write_text(report.read_text() + "altered\n") + rerun = self.run_engine("snakemake", config, root) + self.assertNotEqual(rerun.returncode, 0, rerun.stdout + rerun.stderr) + + def test_failed_snakemake_republish_can_retry(self): + case = {item["id"]: item for item in self.cases}["exact"] + with TemporaryDirectory() as temp: + root = Path(temp) + config = self.config_for(case, root) + first = self.run_engine("snakemake", config, root) + self.assertEqual(first.returncode, 0, first.stdout + first.stderr) + directory = Path(config["output_dir"]) / config["run_id"] + published = directory / "reference.fastaguard.html" + original = published.read_bytes() + staged = directory / ".staging/reference.fastaguard.html" + staged.write_bytes(original + b"changed\n") + failed = self.run_engine( + "snakemake", config, root, + extra_args=("--forcerun", "publish_reports"), + ) + self.assertNotEqual(failed.returncode, 0) + self.assertEqual(published.read_bytes(), original) + self.assertTrue((directory / ".reference-inputs.sha256").is_file()) + staged.write_bytes(original) + retry = self.run_engine("snakemake", config, root) + self.assertEqual(retry.returncode, 0, retry.stdout + retry.stderr) + self.assertTrue( + (directory / ".reference-published").is_file(), + retry.stdout + retry.stderr, + ) + + +if __name__ == "__main__": + unittest.main() From 19107b7e13e6e5b98f5ae6134876b9553405ca03 Mon Sep 17 00:00:00 2001 From: Ehsan ESTAJI <71376358+ehsanestaji@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:13:18 +0200 Subject: [PATCH 2/3] ci: require reference workflow engine tests Signed-off-by: Ehsan ESTAJI <71376358+ehsanestaji@users.noreply.github.com> --- .github/workflows/ci.yml | 61 ++++++++++++++++++++++++ examples/reference/README.md | 18 +++++++ tests/python/test_reference_workflows.py | 39 +++++++++++++++ 3 files changed, 118 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b103560..57f2932 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,67 @@ jobs: path: target/ncbi-policy.json if-no-files-found: error + reference-workflows: + name: Reference workflows + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + + - name: Install Nextflow + uses: nf-core/setup-nextflow@893c28b667aedeba26e37f296d260ccc5bc4d914 # v3.0.1 + with: + version: "26.04.6" + java-version: "17" + + - name: Install Snakemake and test dependencies + run: python3 -m pip install --requirement requirements-test.txt "snakemake==9.25.2" + + - name: Build FastaGuard + run: cargo build --locked --bin fastaguard + + - name: Check workflow engine versions + run: | + nextflow -version + snakemake --version + + - name: Lint reference workflows + run: | + nextflow lint examples/reference/nextflow/main.nf + snakemake --snakefile examples/reference/snakemake/Snakefile \ + --configfile examples/reference/snakemake/config.json \ + --config binary="$PWD/target/debug/fastaguard" --lint + + - name: Test reference workflows with both engines required + env: + FASTAGUARD_REQUIRE_WORKFLOW_ENGINES: "1" + run: | + python3 -m pytest -q -ra tests/python/test_reference_workflows.py \ + --junitxml=target/reference-workflow-tests.xml + + - name: Upload workflow test results + if: always() + uses: actions/upload-artifact@v4.6.2 + with: + name: reference-workflow-tests + path: target/reference-workflow-tests.xml + if-no-files-found: warn + multiqc-plugin: name: MultiQC plugin (Python ${{ matrix.python }}, MultiQC ${{ matrix.multiqc }}) runs-on: ubuntu-latest diff --git a/examples/reference/README.md b/examples/reference/README.md index 158164a..a8b5651 100644 --- a/examples/reference/README.md +++ b/examples/reference/README.md @@ -68,3 +68,21 @@ Object-store publication and multiple bundles are outside their scope. The small [fixture manifest](fixtures/workflow-cases.json) defines eight expected decision cases used by the engine integration tests. It is test data, not biological validation evidence. + +## Testing + +The dedicated GitHub CI job uses Nextflow 26.04.6, Java 17, Snakemake 9.25.2, +and Python 3.12. It runs both workflow linters and the full reference test +file, including the decision matrix, report retention, and retry checks. + +With both engines on `PATH` and the Python test dependencies installed: + +```bash +cargo build --locked --bin fastaguard +FASTAGUARD_REQUIRE_WORKFLOW_ENGINES=1 \ + python3 -m pytest -q -ra tests/python/test_reference_workflows.py +``` + +Required mode fails when an engine is unavailable and rejects a +`FASTAGUARD_WORKFLOW_CASE` filter. Without required mode, local tests may +skip an unavailable engine. diff --git a/tests/python/test_reference_workflows.py b/tests/python/test_reference_workflows.py index b30b273..c09e9d4 100644 --- a/tests/python/test_reference_workflows.py +++ b/tests/python/test_reference_workflows.py @@ -6,6 +6,7 @@ import unittest from pathlib import Path from tempfile import TemporaryDirectory +from unittest.mock import patch import jsonschema @@ -20,6 +21,11 @@ class ReferenceWorkflowTest(unittest.TestCase): @classmethod def setUpClass(cls): + if ( + os.environ.get("FASTAGUARD_REQUIRE_WORKFLOW_ENGINES") == "1" + and os.environ.get("FASTAGUARD_WORKFLOW_CASE") + ): + raise RuntimeError("Required workflow tests must run every fixture case") cls.cases = json.loads(MANIFEST.read_text())["cases"] cls.binary = Path( os.environ.get("FASTAGUARD_BINARY", ROOT / "target/debug/fastaguard") @@ -116,6 +122,8 @@ def nextflow_command(self): executable = shutil.which("nextflow") if executable: return [executable] + if os.environ.get("FASTAGUARD_REQUIRE_WORKFLOW_ENGINES") == "1": + self.fail("Nextflow executable unavailable") self.skipTest("Nextflow executable unavailable") def snakemake_command(self): @@ -124,6 +132,8 @@ def snakemake_command(self): ) if executable: return [executable] + if os.environ.get("FASTAGUARD_REQUIRE_WORKFLOW_ENGINES") == "1": + self.fail("Snakemake executable unavailable") self.skipTest("Snakemake executable unavailable") def run_engine(self, engine, config, root, resume=False, extra_args=(), env=None): @@ -492,5 +502,34 @@ def test_failed_snakemake_republish_can_retry(self): ) +class WorkflowEngineRequirementsTest(unittest.TestCase): + def test_missing_engines_skip_when_optional(self): + case = ReferenceWorkflowTest("test_nextflow_matrix") + with patch.dict(os.environ, {}, clear=True), patch("shutil.which", return_value=None): + for command in (case.nextflow_command, case.snakemake_command): + with self.subTest(engine=command.__name__): + with self.assertRaises(unittest.SkipTest): + command() + + def test_missing_engines_fail_when_required(self): + case = ReferenceWorkflowTest("test_nextflow_matrix") + with patch.dict( + os.environ, {"FASTAGUARD_REQUIRE_WORKFLOW_ENGINES": "1"}, clear=True + ), patch("shutil.which", return_value=None): + for command in (case.nextflow_command, case.snakemake_command): + with self.subTest(engine=command.__name__): + with self.assertRaises(Exception) as raised: + command() + self.assertIsInstance(raised.exception, AssertionError) + + def test_required_matrix_rejects_a_single_case_filter(self): + with patch.dict(os.environ, { + "FASTAGUARD_REQUIRE_WORKFLOW_ENGINES": "1", + "FASTAGUARD_WORKFLOW_CASE": "exact", + }, clear=True): + with self.assertRaisesRegex(RuntimeError, "every fixture case"): + ReferenceWorkflowTest.setUpClass() + + if __name__ == "__main__": unittest.main() From 71e23cf5f743039730c05d504b5c43683ab14bc3 Mon Sep 17 00:00:00 2001 From: Ehsan ESTAJI <71376358+ehsanestaji@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:25:55 +0200 Subject: [PATCH 3/3] chore: prepare v1.1.0 release Signed-off-by: Ehsan ESTAJI <71376358+ehsanestaji@users.noreply.github.com> --- CITATION.cff | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 6 +- docs/packaging.md | 13 +++- docs/releases/v1.1.0.md | 58 ++++++++++++++++ docs/roadmap.md | 14 ++++ .../reports/assembly_fail/fastaguard.json | 2 +- .../assembly_fail/fastaguard_report.html | 2 +- .../reports/assembly_pass/fastaguard.json | 2 +- .../assembly_pass/fastaguard_report.html | 2 +- tests/golden/compare_all_pass.json | 2 +- tests/golden/compare_mixed_status.json | 2 +- tests/golden/invalid_empty_record.json | 2 +- tests/golden/problem_assembly.json | 2 +- tests/golden/valid_assembly.json | 2 +- tests/python/test_release_metadata.py | 69 ++++++++++++++----- 17 files changed, 151 insertions(+), 33 deletions(-) create mode 100644 docs/releases/v1.1.0.md diff --git a/CITATION.cff b/CITATION.cff index 6534004..721d628 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,7 +2,7 @@ cff-version: "1.2.0" message: "If you use FastaGuard, please cite this software." type: software title: "FastaGuard" -version: "1.0.0" +version: "1.1.0" repository-code: "https://github.com/ehsanestaji/FastaGuard" license: "MIT" authors: diff --git a/Cargo.lock b/Cargo.lock index 1b3d8fe..c2a8625 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -505,7 +505,7 @@ dependencies = [ [[package]] name = "fastaguard" -version = "1.0.0" +version = "1.1.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 8f432b2..0fa8c3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fastaguard" -version = "1.0.0" +version = "1.1.0" edition = "2021" license = "MIT" description = "FASTA preflight QC for assembly pipelines" diff --git a/README.md b/README.md index 378c442..9f9796a 100644 --- a/README.md +++ b/README.md @@ -49,12 +49,16 @@ downstream tool. | Channel | Status | | --- | --- | +| Development source | `v1.1.0` (unreleased) | | Source release | `v1.0.0` Reference Contract Gate | | GitHub release | `v1.0.0` release binaries and checksums | | Bioconda | `v1.0.0` is live for `linux-64`, `linux-aarch64`, `osx-64`, and `osx-arm64` | | BioContainers | `1.0.0--hfa8f182_0` is the published pinned workflow image | | Source build | local checkout builds report the package version from `Cargo.toml` | +The [v1.1.0 release notes](docs/releases/v1.1.0.md) describe the reference +workflow examples and CI checks being prepared for the next release. + ## Install For the shortest published-version path from installation to four local @@ -503,7 +507,7 @@ FastaGuard catches FASTA-level assembly problems before expensive assembly QC. - [v0.1.0 release notes](docs/releases/v0.1.0.md) - [Roadmap](docs/roadmap.md) -Citation metadata for the v1.0.0 source release is provided in +Citation metadata for this source version is provided in [`CITATION.cff`](CITATION.cff). ## Status diff --git a/docs/packaging.md b/docs/packaging.md index 3689230..4cc6063 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -19,6 +19,11 @@ The current published package includes the `--gate submission` and `--submission-target generic|ncbi` contract. Keep future docs pinned to confirmed Bioconda and BioContainers versions before advertising them as live. +The development source targets v1.1.0, which is not yet released. Its +[release notes](releases/v1.1.0.md) describe the planned workflow additions. +Keep the recipe, source archive checksum and workflow image pins at the +published version until the new source archive and packages are available. + ## Bioconda Recommended install: @@ -106,8 +111,12 @@ Build and inspect a host archive locally: ```bash cargo build --release --locked -scripts/package_release_artifact.sh "$(rustc -vV | sed -n 's/^host: //p')" v1.0.0 -tar -tzf "dist/fastaguard-v1.0.0-$(rustc -vV | sed -n 's/^host: //p').tar.gz" +package_id="$(cargo pkgid --quiet)" +release_version="${package_id##*@}" +host_target="$(rustc -vV | sed -n 's/^host: //p')" +scripts/check_release_tag.sh "v${release_version}" +scripts/package_release_artifact.sh "$host_target" "v${release_version}" +tar -tzf "dist/fastaguard-v${release_version}-${host_target}.tar.gz" ``` Each archive has one top-level directory containing `fastaguard`, `README.md`, diff --git a/docs/releases/v1.1.0.md b/docs/releases/v1.1.0.md new file mode 100644 index 0000000..6f8d690 --- /dev/null +++ b/docs/releases/v1.1.0.md @@ -0,0 +1,58 @@ +# FastaGuard v1.1.0 - Reference Workflow Adoption + +Status: unreleased. This source tree targets v1.1.0; published downloads remain +at v1.0.0. + +## Reference workflows + +This release adds Nextflow and Snakemake examples for using +`fastaguard reference` before downstream work. Both support a single reference +bundle on a local or shared filesystem, with explicit FASTA and companion-file +dependencies. + +- `report` mode saves the reports without enforcing the compatibility decision. +- `enforce` mode checks `gate.can_continue` before a demonstration downstream + task can run. +- A blocked gate leaves the completed HTML, JSON, TSV and MultiQC reports + available for inspection. A reference lockfile is optional. +- Input and report checks protect existing evidence during retry or resume; + changed inputs require a new run identifier. + +The [workflow examples](../../examples/reference/README.md) include the +configuration files and synthetic fixtures. They are supplied in the repository +source, not the standalone binary archives. They do not modify input files or +perform a biological downstream analysis. Object-store publication and +multi-bundle orchestration are outside their scope. + +## Verification and documentation + +The dedicated CI job pins Nextflow 26.04.6 with Java 17 and Snakemake 9.25.2 +with Python 3.12. It runs both workflow linters, the eight-case decision matrix, +report retention and retry checks. Required mode fails if an engine is missing +or a fixture filter would reduce the matrix. + +The reference quickstart and installation guidance now separate source builds +from published packages. Reference memory use, identity guarantees and +publication limits are documented more precisely. The workflow fixtures test +software behaviour; they are not additional biological validation evidence. + +## Compatibility + +There are no changes to the CLI's analysis or exit-code contracts. Assembly and +compare reports retain schema version `0.7.0`. Reference reports retain schema +version `1.0.0`. The new workflow examples also work with the published v1.0.0 +reference command. + +The existing nf-core module and Snakemake wrapper are unchanged. These local +reference examples are not new upstream workflow components. + +## Distribution + +Bioconda and BioContainers remain at v1.0.0. The recipe checksum, container tags +and upstream wrapper pins remain tied to that published version. They will be +updated only after the v1.1.0 source archive and downstream packages are +available. + +Before publication, the release commit must pass hosted CI and the +cross-platform release builds. The release archives and checksum manifest must +then be verified. Preparing this source tree does not publish a release. diff --git a/docs/roadmap.md b/docs/roadmap.md index 5a348a1..125b8fe 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -7,6 +7,20 @@ GitHub, Bioconda, and BioContainers. Bioconda serves `linux-64`, `linux-aarch64`, `osx-64`, and `osx-arm64`; the published BioContainers tag is `1.0.0--hfa8f182_0`. +## v1.1: Reference Workflow Adoption + +Status: in preparation, not yet released. + +- tested Nextflow and Snakemake examples for a single reference bundle +- separate reporting and enforcement, with reports retained when a gate blocks +- explicit input dependencies and checks for changed inputs or reports on retry +- pinned workflow-engine CI that requires the full decision matrix +- clearer installation, reference quickstart and compatibility guidance + +This release keeps the v1.0 command-line behaviour and report schemas. +It does not introduce new upstream nf-core or Snakemake reference components. +See the [v1.1.0 release notes](releases/v1.1.0.md) for scope and limitations. + ## v1.0: Reference Contract Gate Goal: diff --git a/examples/reports/assembly_fail/fastaguard.json b/examples/reports/assembly_fail/fastaguard.json index d71d1b0..e87edc4 100644 --- a/examples/reports/assembly_fail/fastaguard.json +++ b/examples/reports/assembly_fail/fastaguard.json @@ -3,7 +3,7 @@ "report_type": "assembly", "tool": { "name": "FastaGuard", - "version": "1.0.0" + "version": "1.1.0" }, "input": { "path": "testdata/problem_assembly.fa", diff --git a/examples/reports/assembly_fail/fastaguard_report.html b/examples/reports/assembly_fail/fastaguard_report.html index 2875a4d..8ad6663 100644 --- a/examples/reports/assembly_fail/fastaguard_report.html +++ b/examples/reports/assembly_fail/fastaguard_report.html @@ -582,7 +582,7 @@

JSON

"report_type": "assembly", "tool": { "name": "FastaGuard", - "version": "1.0.0" + "version": "1.1.0" }, "input": { "path": "testdata/problem_assembly.fa", diff --git a/examples/reports/assembly_pass/fastaguard.json b/examples/reports/assembly_pass/fastaguard.json index c50a4aa..9b992d1 100644 --- a/examples/reports/assembly_pass/fastaguard.json +++ b/examples/reports/assembly_pass/fastaguard.json @@ -3,7 +3,7 @@ "report_type": "assembly", "tool": { "name": "FastaGuard", - "version": "1.0.0" + "version": "1.1.0" }, "input": { "path": "testdata/valid_assembly.fa", diff --git a/examples/reports/assembly_pass/fastaguard_report.html b/examples/reports/assembly_pass/fastaguard_report.html index 759a0a4..fb34bd7 100644 --- a/examples/reports/assembly_pass/fastaguard_report.html +++ b/examples/reports/assembly_pass/fastaguard_report.html @@ -204,7 +204,7 @@

JSON

"report_type": "assembly", "tool": { "name": "FastaGuard", - "version": "1.0.0" + "version": "1.1.0" }, "input": { "path": "testdata/valid_assembly.fa", diff --git a/tests/golden/compare_all_pass.json b/tests/golden/compare_all_pass.json index 7a7bde0..f62113b 100644 --- a/tests/golden/compare_all_pass.json +++ b/tests/golden/compare_all_pass.json @@ -3,7 +3,7 @@ "report_type": "compare", "tool": { "name": "FastaGuard", - "version": "1.0.0" + "version": "1.1.0" }, "input": { "profile": "assembly", diff --git a/tests/golden/compare_mixed_status.json b/tests/golden/compare_mixed_status.json index bc7f172..14ad7aa 100644 --- a/tests/golden/compare_mixed_status.json +++ b/tests/golden/compare_mixed_status.json @@ -3,7 +3,7 @@ "report_type": "compare", "tool": { "name": "FastaGuard", - "version": "1.0.0" + "version": "1.1.0" }, "input": { "profile": "assembly", diff --git a/tests/golden/invalid_empty_record.json b/tests/golden/invalid_empty_record.json index 0e0953f..2b99a0f 100644 --- a/tests/golden/invalid_empty_record.json +++ b/tests/golden/invalid_empty_record.json @@ -3,7 +3,7 @@ "report_type": "assembly", "tool": { "name": "FastaGuard", - "version": "1.0.0" + "version": "1.1.0" }, "input": { "path": "testdata/invalid_empty_record.fa", diff --git a/tests/golden/problem_assembly.json b/tests/golden/problem_assembly.json index 7ebe96a..2587572 100644 --- a/tests/golden/problem_assembly.json +++ b/tests/golden/problem_assembly.json @@ -3,7 +3,7 @@ "report_type": "assembly", "tool": { "name": "FastaGuard", - "version": "1.0.0" + "version": "1.1.0" }, "input": { "path": "testdata/problem_assembly.fa", diff --git a/tests/golden/valid_assembly.json b/tests/golden/valid_assembly.json index fde0e6a..bf6d93c 100644 --- a/tests/golden/valid_assembly.json +++ b/tests/golden/valid_assembly.json @@ -3,7 +3,7 @@ "report_type": "assembly", "tool": { "name": "FastaGuard", - "version": "1.0.0" + "version": "1.1.0" }, "input": { "path": "testdata/valid_assembly.fa", diff --git a/tests/python/test_release_metadata.py b/tests/python/test_release_metadata.py index 0963f94..8441296 100644 --- a/tests/python/test_release_metadata.py +++ b/tests/python/test_release_metadata.py @@ -12,7 +12,8 @@ ROOT = Path(__file__).resolve().parents[2] -CURRENT_VERSION = "1.0.0" +SOURCE_VERSION = "1.1.0" +PUBLISHED_VERSION = "1.0.0" CURRENT_CONTAINER = "1.0.0--hfa8f182_0" CURRENT_SOURCE_SHA256 = ( "427bd1b9cf406f42e9754bdf2e79370c3c879249cfb87e8a4a7c9dbfa6dc4cfa" @@ -37,10 +38,41 @@ def make_packaging_fixture(self, temp_path): binary.chmod(0o755) return project - def test_package_targets_v1_0_0_release(self): + def test_package_targets_v1_1_0_release(self): cargo = tomllib.loads((ROOT / "Cargo.toml").read_text()) - self.assertEqual(cargo["package"]["version"], "1.0.0") + self.assertEqual(cargo["package"]["version"], SOURCE_VERSION) + + def test_cargo_lock_matches_source_version(self): + lock = tomllib.loads((ROOT / "Cargo.lock").read_text()) + package = next(item for item in lock["package"] if item["name"] == "fastaguard") + + self.assertEqual(package["version"], SOURCE_VERSION) + + def test_v1_1_0_release_notes_define_workflow_scope(self): + notes = ROOT / "docs/releases/v1.1.0.md" + + self.assertTrue(notes.is_file()) + text = " ".join(notes.read_text().split()) + for expected in ( + "FastaGuard v1.1.0", + "Status: unreleased", + "Nextflow", "Snakemake", "report", "enforce", + "gate.can_continue", "report retention", "retry", + "Assembly and compare reports retain schema version `0.7.0`", + "Reference reports retain schema version `1.0.0`", + "Bioconda and BioContainers remain at v1.0.0", + "nf-core", "single reference bundle", "shared filesystem", + ): + with self.subTest(expected=expected): + self.assertIn(expected, text) + + def test_readme_separates_development_from_published_release(self): + text = (ROOT / "README.md").read_text() + + self.assertIn("| Development source | `v1.1.0` (unreleased) |", text) + self.assertIn("| Source release | `v1.0.0` Reference Contract Gate |", text) + self.assertIn("docs/releases/v1.1.0.md", text) def test_v1_0_0_release_notes_define_reference_contract(self): notes = ROOT / "docs" / "releases" / "v1.0.0.md" @@ -69,7 +101,7 @@ def test_release_tag_must_match_manifest_version(self): self.assertTrue(checker.is_file(), checker) matching = subprocess.run( - [str(checker), "v1.0.0"], + [str(checker), f"v{SOURCE_VERSION}"], cwd=ROOT, capture_output=True, text=True, @@ -77,16 +109,17 @@ def test_release_tag_must_match_manifest_version(self): ) self.assertEqual(matching.returncode, 0, matching.stderr) + mismatching_tag = "v0.0.0" mismatching = subprocess.run( - [str(checker), "v1.0.1"], + [str(checker), mismatching_tag], cwd=ROOT, capture_output=True, text=True, check=False, ) self.assertNotEqual(mismatching.returncode, 0) - self.assertIn("v1.0.1", mismatching.stderr) - self.assertIn("v1.0.0", mismatching.stderr) + self.assertIn(mismatching_tag, mismatching.stderr) + self.assertIn(f"v{SOURCE_VERSION}", mismatching.stderr) def test_release_workflow_validates_tag_before_packaging(self): workflow = yaml.safe_load( @@ -114,7 +147,7 @@ def test_release_archive_contains_runtime_and_contract_assets(self): [ str(ROOT / "scripts" / "package_release_artifact.sh"), "test-target", - "v1.0.0", + f"v{SOURCE_VERSION}", str(binary), str(dist), ], @@ -131,15 +164,15 @@ def test_release_archive_contains_runtime_and_contract_assets(self): self.assertTrue(archive.exists(), archive) self.assertEqual( archive.name, - "fastaguard-v1.0.0-test-target.tar.gz", + f"fastaguard-v{SOURCE_VERSION}-test-target.tar.gz", ) with tarfile.open(archive, "r:gz") as package: names = set(package.getnames()) top_levels = {name.split("/", 1)[0] for name in names} - self.assertEqual(top_levels, {"fastaguard-v1.0.0-test-target"}) - root = "fastaguard-v1.0.0-test-target" + self.assertEqual(top_levels, {f"fastaguard-v{SOURCE_VERSION}-test-target"}) + root = f"fastaguard-v{SOURCE_VERSION}-test-target" for expected in [ f"{root}/fastaguard", f"{root}/README.md", @@ -163,7 +196,7 @@ def test_release_archive_uses_host_binary_fallback_only_for_host_target(self): [ str(project / "scripts" / "package_release_artifact.sh"), host, - "v1.0.0", + f"v{SOURCE_VERSION}", ], cwd=project, capture_output=True, @@ -173,7 +206,7 @@ def test_release_archive_uses_host_binary_fallback_only_for_host_target(self): self.assertEqual(completed.returncode, 0, completed.stderr) self.assertTrue( - (project / "dist" / f"fastaguard-v1.0.0-{host}.tar.gz").exists() + (project / "dist" / f"fastaguard-v{SOURCE_VERSION}-{host}.tar.gz").exists() ) def test_release_archive_rejects_host_fallback_for_foreign_target(self): @@ -190,7 +223,7 @@ def test_release_archive_rejects_host_fallback_for_foreign_target(self): [ str(project / "scripts" / "package_release_artifact.sh"), foreign_target, - "v1.0.0", + f"v{SOURCE_VERSION}", ], cwd=project, capture_output=True, @@ -399,7 +432,7 @@ def test_bioconda_build_script_uses_portable_install(self): def test_docs_reference_published_bioconda_install(self): install_command = "mamba install -c conda-forge -c bioconda fastaguard" - pinned_install = install_command + f"={CURRENT_VERSION}" + pinned_install = install_command + f"={PUBLISHED_VERSION}" container = f"quay.io/biocontainers/fastaguard:{CURRENT_CONTAINER}" docs = [ ROOT / "README.md", @@ -430,8 +463,8 @@ def test_biotools_downloads_match_current_distribution(self): (ROOT / "docs/biotools-registration.json").read_text() )[0] downloads = {item["type"]: item for item in registration["download"]} - self.assertEqual(downloads["Binaries"]["version"], CURRENT_VERSION) - self.assertEqual(downloads["Software package"]["version"], CURRENT_VERSION) + self.assertEqual(downloads["Binaries"]["version"], PUBLISHED_VERSION) + self.assertEqual(downloads["Software package"]["version"], PUBLISHED_VERSION) self.assertEqual(downloads["Container file"]["version"], CURRENT_CONTAINER) def test_current_distribution_docs_do_not_claim_updates_are_pending(self): @@ -479,7 +512,7 @@ def test_current_release_docs_do_not_present_v0_5_as_latest(self): for path in current_docs: with self.subTest(path=path): text = path.read_text() - self.assertIn(CURRENT_VERSION, text) + self.assertIn(PUBLISHED_VERSION, text) for claim in stale_claims: self.assertNotIn(claim, text)