diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index edb78f85..9a6963f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + checks: read steps: # The tag comes from the dispatch form or the pushed ref, so it is # untrusted text: it reaches the shell through env only, and it must @@ -43,45 +44,36 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Generate release notes - id: notes + # A release is named after the version the pipeline reports, so the tag + # must equal manifest.version in nextflow.config at the tagged commit, + # and a major or minor release needs its curated notes, + # docs/releases/.md, which become the release body. + - name: The tag matches manifest.version, and the notes exist env: TAG: ${{ steps.tag.outputs.tag }} + run: scripts/ci/check-release.sh tag "$TAG" --notes-out "${RUNNER_TEMP}/notes.md" + + # Only a commit that main's CI passed is released: it must be on main, + # and every check run on it (this job left out) must have finished green. + - name: The tagged commit is on main and its checks passed + env: + TAG: ${{ steps.tag.outputs.tag }} + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} run: | - # Verify the tag exists in the repo - if ! git rev-parse "${TAG}" >/dev/null 2>&1; then - echo "ERROR: Tag ${TAG} does not exist in this repository" >&2 + sha=$(git rev-parse "${TAG}^{commit}") + if ! git merge-base --is-ancestor "$sha" origin/main; then + echo "ERROR: ${TAG} (${sha}) is not a commit on main" >&2 exit 1 fi - - # Find previous tag - PREV_TAG=$(git tag --sort=-version:refname | grep '^v' | grep -v "^${TAG}$" | head -1) - - if [ -n "$PREV_TAG" ]; then - RANGE="${PREV_TAG}..${TAG}" - else - RANGE="${TAG}" - fi - - { - echo "notes</dev/null || echo "- Initial release" - echo "" - echo "" - echo "## Docker Images" - echo "" - echo "All image versions are listed in [\`versions.env\`](versions.env)." - echo "" - echo "To set up: \`./scripts/setup.sh /path/to/data\`" - echo "EOF" - } >> "$GITHUB_OUTPUT" + gh api --paginate "repos/${REPO}/commits/${sha}/check-runs?per_page=100" \ + --jq '.check_runs[] | [.name, .status, (.conclusion // "")] | @tsv' > "${RUNNER_TEMP}/runs.tsv" + scripts/ci/check-release.sh checks "${RUNNER_TEMP}/runs.tsv" --ignore "$GITHUB_JOB" - name: Create GitHub Release uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: tag_name: ${{ steps.tag.outputs.tag }} name: ${{ steps.tag.outputs.tag }} - body: ${{ steps.notes.outputs.notes }} + body_path: ${{ runner.temp }}/notes.md draft: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..db60d40b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +Each release's notes. From 1.0.0 on, a major or minor release has hand-written notes under `docs/releases/`, and the release workflow uses them as the release body. Older releases list their commits on GitHub. + +- 1.0.0: [docs/releases/v1.0.0.md](docs/releases/v1.0.0.md) +- 0.2.0 to 0.8.2: https://github.com/GeiserX/OwnGenome/releases diff --git a/bin/render_report.py b/bin/render_report.py index 1688f9c2..df89572a 100755 --- a/bin/render_report.py +++ b/bin/render_report.py @@ -1445,7 +1445,8 @@ def head(key, title=None): m = s["manifest"] if m: commit = next((r[2] for r in m if r[0] == "run" and r[1] == "git_commit"), "unknown") - w(f"Run manifest: run_manifest.tsv (pipeline commit {commit}, " + release = next((r[2] for r in m if r[0] == "run" and r[1] == "pipeline_version"), None) + w(f"Run manifest: run_manifest.tsv ({f'OwnGenome {release}, ' if release else ''}pipeline commit {commit}, " f"{sum(1 for r in m if r[0] == 'image')} images)") w("=" * 80) w(" DISCLAIMER: This is NOT a clinical report. Discuss findings with a") @@ -3134,6 +3135,8 @@ def html_page(s, parts, index=None): S = s["sections"] m = s["manifest"] commit = next((r[2] for r in m if r[0] == "run" and r[1] == "git_commit"), None) if m else None + # The OwnGenome release the run declared (nextflow.config's manifest.version), when the manifest records it. + release = next((r[2] for r in m if r[0] == "run" and r[1] == "pipeline_version"), None) if m else None db = s["databases"] ns = s.get("new_since") or {} changed = set(ns.get("sections") or []) @@ -3180,7 +3183,8 @@ def html_page(s, parts, index=None): f" Sample: {E(s['sample'])}  |  Generated: {E(s['generated_utc'])}" + (f"  |  Latest run: {E(s['run']['started_utc'])}" if s["run"]["started_utc"] else ""), " ", '
', - f" Pipeline commit: {E(commit or 'unknown')}  |  ClinVar file: {E(db['clinvar_release'] or 'unknown')}" + (f" OwnGenome release: {E(release)}  | " if release else "") + + f" Pipeline commit: {E(commit or 'unknown')}  |  ClinVar file: {E(db['clinvar_release'] or 'unknown')}" f"  |  ClinVar in CPSR's bundle: {E(db['cpsr_clinvar_release'] or 'unknown')}" f"  |  HLA database: {E(db['hla_database'] or 'unknown')}", "
"] + ([f'
{E(recheck_line(s["recheck"]))}
'] if s.get("recheck") else []) + [ diff --git a/docs/releases/v1.0.0.md b/docs/releases/v1.0.0.md new file mode 100644 index 00000000..e16b0354 --- /dev/null +++ b/docs/releases/v1.0.0.md @@ -0,0 +1,117 @@ +# OwnGenome 1.0.0 + +> **Not released yet.** 1.0.0 is not tagged. Some entries below describe work still landing on main, and the compare link at the end works once the tag exists. The release removes this notice: the release checks refuse notes that still carry it. + +OwnGenome 1.0.0 is the first stable release. It is one Nextflow pipeline that takes your FASTQ, BAM or VCF and runs every step on your own machine. These notes cover everything since 0.8.2, grouped by what you get. + +## Before you upgrade + +- **Realign your samples.** The default reference is now NCBI's GRCh38 analysis set without ALT contigs. With ALT contigs, reads at CYP2D6, the MHC and KIR got mapping quality 0, so depth there was wrong. `validate-setup.sh` refuses a BAM aligned to another reference. How to move: https://geiserx.github.io/OwnGenome/realignment/ +- **Java 17+ and Nextflow are needed.** `run-all.sh` keeps its arguments, writes a one-row samplesheet and starts `nextflow run main.nf -resume`. The bash scheduler is gone. The numbered scripts still run one step each. +- **The project has a new name: OwnGenome.** The repository, the docs site and the report carry it, with shims for the old command names. +- **1.0 makes a promise.** Command options, config keys and output file names and formats stay as they are until a 2.0. A new column or file can be added in a 1.x; a rename or removal waits for 2.0. The output contract lists what is covered, and CI fails when a stable output breaks. + +## One pipeline from raw reads + +- The Nextflow pipeline starts from FASTQ: fastp, minimap2 alignment with duplicate marking and the BAM index in one pass, and DeepVariant writing a VCF and a gVCF. Before, Nextflow started from a VCF and the alignment existed only as bash. +- The gVCF means PRS and PharmCAT see the sites where you match the reference, instead of treating them as missing. +- The alignment fails when the BAM lost input reads, instead of passing a BAM with a third missing. +- A VCF from a vendor is checked before any step runs: a GRCh37 file, a multi-sample file and a file without contig lengths are refused with the reason. +- `run-all.sh` resumes, keeps per-step logs, starts from the BAM and VCF an earlier run produced, and runs the sample identity and contamination checks in a default run. +- `bin/owngenome_recheck.py` says which results a newer ClinVar or catalog would change, and `--rerun` reruns only those. + +## Watching a run + +- A live site shows a run as it happens, from any device, behind a password built into the server. The invented demo sample lets anyone try it. +- The run says which steps are planned, running and done, how long each step usually takes on this machine, and how many hours are left, as a range. +- It warns before a task runs out of memory or a disk fills, and before a run stalls, on Linux, macOS and WSL2. + +## The report + +- The HTML report reads in seven chapters with a table of contents. One section prints on its own, with its caveats. +- A confidence mark on each finding, and charts that keep low-star and low-confidence data visible. +- One search box finds any gene, rsID or variant across every result, including repeat expansions, Locityper loci and the CFTR and UGT1A1 readings. +- The report says what changed since the previous run of the same sample, and the run manifest records which databases and images produced it. +- The report says which genes this genome could not read reliably, instead of showing them as normal, and says plainly what short reads cannot see. +- Any finding opens in a local genome viewer with your reads, from the report or the live site. Nothing is uploaded. +- A gene hit twice across engines is flagged, for example a deletion plus a variant on the other copy. +- The Nextflow report gets every table the bash report reads: ClinVar frequencies and rsIDs, CPSR classes, read counts and the clinical and slivar detail. + +## Clinical findings + +- ClinVar hits need a depth and genotype-quality floor and a population frequency. Lab-construct contamination (cDNA reads clipped at exon edges) is flagged, and known deep intronic sites are always reported. +- A delins the caller split into adjacent records matches the combined ClinVar record. +- Heterozygous MUTYH or HFE carriers no longer show as secondary findings; deletions in secondary-finding genes do. +- Exomiser ranks the variants that could explain a list of HPO terms. +- The clinical filter ranks HIGH impacts on the MANE transcript and gives indels of unknown frequency their own tier. +- Compound heterozygous pairs are phased from the reads: cis, trans or unknown. + +## Structural variants and copy number + +- A real SV merge replaces position binning. AnnotSV class 4 and 5 variants reach the reports with caller support, exon overlap and population frequency, and AnnotSV keeps genotype and phase so two deletions pair in trans. +- Common repeat, immune-locus and MHC SVs, single-junction Manta breakends and long Delly calls that read depth contradicts no longer show as likely pathogenic. +- A filtered or 0/0 Manta record no longer hides a deletion another caller found, and chrX depth is read by sex and PAR. +- Copy number at any locus comes from one catalog row: HP, LCE3, CCL3L1, CYP21A2 deletions and gene conversions, and recurrent deletions such as 1q21.1 and AZF from a pinned ClinGen list. These results reach both reports and the couple screen. +- Paraphase and Parascopy call alpha-globin, CYP21A2, GBA, PMS2, STRC, NCF1, OTOA and SMN1 copy number, and the report lists every paralog gene in the catalogs. +- GRCh38's false duplications are masked, so genes such as U2AF1 can be read. +- Mobile element insertions (Alu, L1, SVA), including the few known to cause disease. +- Large mosaic gains, losses and copy-neutral LOH in blood, mosaic loss of chromosome Y or X, and large mtDNA deletions with the mtDNA copy count. + +## Repeats + +- 82 STRchive disease repeats are screened instead of the 31 ExpansionHunter bundles, with matching thresholds. Each locus shows its intermediate range and sizing confidence; RFC1 is resolved with a read-level motif count. +- The CFTR 5T/TG tract and the UGT1A1 TA repeat are read directly from the reads. +- The MUC1 repeat behind ADTKD is checked, which no standard caller sees. +- Locityper genotypes the hardest polymorphic genes as an opt-in step. + +## Pharmacogenomics + +- pypgx calls deletions and hybrids for all 13 of its SV genes, shows copy-number calls with their confidence, and says when a gene cannot be called on this reference. +- Drug guidance for HLA and CYP2D6 comes only when two callers agree. OptiType is an optional second HLA caller beside T1K. +- An optional medication list reports CYP2D6 and CYP2C19 phenoconversion, and the report warns when a medicine changes a call or is contraindicated (for example valproate with POLG). +- MT-RNR1 is called from the mtDNA variants. Indel-defined alleles are called reference when the gVCF holds a confident reference block over them. +- HLA drug alleles and the DQ2.5, DQ2.2 and DQ8 types are derived from the T1K alleles. + +## Polygenic scores + +- Scores report an ancestry-adjusted percentile when the ancestry panel is installed, the ancestry each score was built on and how well it predicts. +- Sex-specific scores are scored only for the matching sex, and chrX rows survive. Blood pressure and lipid scores are added. + +## Traits, blood groups and ancestry + +- A traits section: lactase persistence, caffeine, alcohol flush, taste, earwax, APOE and other replicated variants, each with its citation. +- Multi-SNP results (APOE, APOL1, SERPINA1, HFE and others) come from one rule table checked against dbSNP, so a strand mistake fails CI. More risk-allele and marker rows are in the table. +- Blood group antigens across ABO, Rh, Kell, Duffy, Kidd and the other systems, plus the platelet and neutrophil antigens that matter for transfusion and pregnancy. +- Lp(a) from the two risk variants and the KIV-2 repeat count. +- CHIP: clonal blood-cell mutations, with the detection limit at your depth. +- Inherited integrated HHV-6 and viral DNA from the reads that do not map to the human genome. +- Admixture fractions and chromosome painting against an open regional panel, placement among ancient populations, and your Neanderthal and Denisovan share and where it sits. +- The Y line is placed on YFull's current tree, with the SNPs past the deepest known branch. Y-STR repeat counts, mtDNA and Y explore links, and the files those services accept. +- Runs of homozygosity flag a chromosome that may come from one parent, and sex-chromosome aneuploidies are named. +- A 23andMe-layout raw-data file from the gVCF, for third-party tools. +- `bin/genome_lookup.py` answers "what is my genotype at rsX or in gene Y" without opening a VCF. + +## Families and couples + +- One family file says who is related to whom. The pipeline joint-calls the family, checks the declared relationships, phases a child by inheritance and maps which stretches each child got from each parent, ready for DNA Painter. +- Shared DNA segments between two sequenced relatives, on the report and the live site. +- An optional couple screen reports shared recessive and X-linked risk. Common population deletions, short Delly calls and megabase artefacts no longer count as carrier risk. +- A daughter's chrX site that differs from her mother is not called de novo when her father carries the allele. +- Chip data: mitochondrial sites lift over correctly, and MyHeritage files with Windows line endings convert. + +## Setup and reliability + +- `setup.sh` installs the VEP cache up front, retries a download whose checksum or gzip check fails, and keeps digest-pinned images through a cleanup. +- Steps stop trusting half-written files, and a failed upstream command fails its task instead of leaving a short file that looks valid. +- Memory budgets match a real genome for GLnexus, MEI and the PRS steps, and DeepVariant gets its full run time. +- Every image version is one line in `versions.env`. Renovate proposes each update as its own pull request, and CI runs every changed image on a small real-data fixture. + +## Testing + +- The e2e suite runs the real tools on an HG002 slice, in parallel jobs, scoped to what a pull request can affect. A case that checks nothing fails. +- A misspelt Nextflow parameter is caught instead of silently ignored. +- A release tag must match the version the pipeline declares, and a commit main's CI did not pass cannot be released. + +Full commit list: https://github.com/GeiserX/OwnGenome/compare/v0.8.2...v1.0.0 + +Get started: https://geiserx.github.io/OwnGenome/getting-started/ diff --git a/docs/research/nf-core-pipeline-requirements.md b/docs/research/nf-core-pipeline-requirements.md index 06150a96..1a59a57d 100644 --- a/docs/research/nf-core-pipeline-requirements.md +++ b/docs/research/nf-core-pipeline-requirements.md @@ -18,7 +18,8 @@ The report below is a research snapshot. This table says which of its requiremen | Parameter schema validated at run time (nf-schema plugin) | unmet | no `plugins` block; `nextflow_schema.json` is draft-07 and only syntax-checked in CI | | `modules.config` for publishing, `meta.yml` per module, `modules.json` | unmet | every module hard-codes its `publishDir`; all modules are local | | nf-test tests | unmet | no `nf-test.config`; tests are the stub run, the e2e job and scripts under `tests/` | -| `CHANGELOG.md` and `CITATIONS.md` | unmet | release notes live in GitHub releases | +| `CHANGELOG.md` | met | `CHANGELOG.md` links each release's notes; from 1.0.0 they are hand-written in `docs/releases/` and `scripts/ci/check-release.sh` checks them | +| `CITATIONS.md` | unmet | no `CITATIONS.md` | | Zero failures in `nf-core pipelines lint` | unmet | no lint job, and the repo is not built from the template | | Built from the nf-core template, `master`/`dev`/`TEMPLATE` branches | not planned | the pipeline predates the template; one `main` branch | | MIT license | not planned | GPL-3.0-or-later | diff --git a/scripts/ci/check-release.sh b/scripts/ci/check-release.sh new file mode 100755 index 00000000..481573ff --- /dev/null +++ b/scripts/ci/check-release.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# check-release.sh: the checks a release must pass before it is published. +# +# check-release.sh tag TAG [--notes-out FILE] +# TAG (vMAJOR.MINOR.PATCH) must equal "v" + the manifest version that +# nextflow.config declares at TAG, so `nextflow run -r TAG` reports the +# version the release is named after. A major or minor release +# (PATCH = 0) must carry its hand-written notes, docs/releases/TAG.md, +# at TAG. With --notes-out the release body is written to FILE: the +# notes file when TAG has one, else (a patch release only) the commit +# subjects since the previous tag. Notes that still carry the draft +# notice (a line starting "> **Not released yet.**") are refused. +# +# check-release.sh checks RUNS.TSV [--ignore NAME]... +# RUNS.TSV is the tagged commit's check runs, one per line: +# namestatusconclusion (as `gh api .../commits/SHA/check-runs` +# gives them). Every run must have finished, and finished as success, +# skipped or neutral; Guard's ci-ok, which runs on every push to main, +# must be among them. NAME (the release job itself) is left out. +# +# check-release.sh declared +# The working tree: the version nextflow.config declares, and every +# docs/releases/*.md. Each notes file is named vX.Y.Z.md, opens with +# "# OwnGenome X.Y.Z", is linked from CHANGELOG.md, and a declared +# major or minor version has one, without the draft notice. Notes for +# a version not declared yet may carry it. Guard runs this through +# tests/test_release.sh, so a bump to X.Y.0 cannot merge without its +# notes. +# +# --repo DIR runs against another git work tree (the tests plant one); the +# default is the checkout this script is in. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +if [ "${1:-}" = --repo ]; then ROOT=${2:?--repo needs a directory}; shift 2; fi +MODE=${1:-} +shift || true + +die() { echo "ERROR: $*" >&2; exit 1; } +semver='^v([0-9]+)\.([0-9]+)\.([0-9]+)$' +# The notice a notes file carries while its release is still being written. +draft='^> \*\*Not released yet\.\*\*' + +# manifest_version: the version = '...' line inside the manifest { } block of +# nextflow.config on stdin. Exactly one, or an error. +manifest_version() { + local v + v=$(awk ' + /^[[:space:]]*manifest[[:space:]]*\{/ {inside = 1; next} + inside && /^[[:space:]]*\}/ {inside = 0} + inside && /^[[:space:]]*version[[:space:]]*=/ { + s = $0; sub(/^[^=]*=[[:space:]]*/, "", s); sub(/[[:space:]]*(\/\/.*)?$/, "", s) + gsub(/^["\047]|["\047]$/, "", s); print s + }') + [ -n "$v" ] || die "nextflow.config declares no version in its manifest block" + [ "$(printf '%s\n' "$v" | wc -l)" -eq 1 ] || die "nextflow.config declares more than one manifest version" + printf '%s\n' "$v" +} + +# check_notes NAME TEXT: NAME is vX.Y.Z.md; TEXT is its content. +check_notes() { + local name=$1 text=$2 tag v + tag=${name%.md} + [[ "$name" == *.md && "$tag" =~ $semver ]] || die "docs/releases/${name}: a notes file is named vMAJOR.MINOR.PATCH.md" + v=${tag#v} + [ "$(printf '%s\n' "$text" | sed -n '/[^[:space:]]/{p;q;}')" = "# OwnGenome ${v}" ] \ + || die "docs/releases/${name}: the first line must be '# OwnGenome ${v}'" + [ "$(printf '%s\n' "$text" | grep -c '[^[:space:]]')" -ge 2 ] \ + || die "docs/releases/${name}: holds no notes, only a heading" +} + +case "$MODE" in + tag) + TAG=${1:?Usage: $0 tag TAG [--notes-out FILE]} + shift + OUT="" + if [ "${1:-}" = --notes-out ]; then OUT=${2:?--notes-out needs a file}; fi + [[ "$TAG" =~ $semver ]] || die "the tag is not of the form vMAJOR.MINOR.PATCH" + patch=${BASH_REMATCH[3]} + git -C "$ROOT" rev-parse -q --verify "refs/tags/${TAG}" >/dev/null || die "tag ${TAG} does not exist in this repository" + declared=$(git -C "$ROOT" show "${TAG}:nextflow.config" | manifest_version) + [ "v${declared}" = "$TAG" ] \ + || die "tag ${TAG} does not match the version nextflow.config declares at that commit (${declared}): bump manifest.version in a pull request, then tag the merged commit" + notes="docs/releases/${TAG}.md" + if text=$(git -C "$ROOT" show "${TAG}:${notes}" 2>/dev/null); then + check_notes "${TAG}.md" "$text" + ! grep -q "$draft" <<< "$text" || die "${notes} still carries the 'Not released yet' notice: remove it in the pull request that bumps the version" + [ -z "$OUT" ] || printf '%s\n' "$text" > "$OUT" + echo "OK: ${TAG} matches manifest.version, notes from ${notes}" + elif [ "$patch" = 0 ]; then + die "${TAG} is a major or minor release and has no ${notes} at that commit: write its notes first" + else + if [ -n "$OUT" ]; then + prev=$(git -C "$ROOT" tag --sort=-version:refname | grep -E "$semver" | grep -vx "$TAG" \ + | while read -r t; do git -C "$ROOT" merge-base --is-ancestor "$t" "$TAG" 2>/dev/null && { echo "$t"; break; }; done || true) + { + echo "## What's Changed" + echo "" + git -C "$ROOT" log "${prev:+${prev}..}${TAG}" --pretty=format:"- %s" --no-merges + echo "" + echo "" + echo "All image versions are listed in versions.env at this tag." + } > "$OUT" + fi + echo "OK: ${TAG} matches manifest.version, a patch release: notes from the commit subjects" + fi + ;; + checks) + RUNS=${1:?Usage: $0 checks RUNS.TSV [--ignore NAME]...} + shift + ignore=() + while [ $# -gt 0 ]; do + [ "$1" = --ignore ] || die "unknown option: $1" + ignore+=("${2:?--ignore needs a name}") + shift 2 + done + [ -f "$RUNS" ] || die "no check-run list at ${RUNS}" + n=0 bad=0 cios=0 + while IFS=$'\t' read -r name status conclusion || [ -n "$name" ]; do + [ -n "$name" ] || continue + skip=0 + for i in "${ignore[@]+"${ignore[@]}"}"; do [ "$name" != "$i" ] || skip=1; done + [ "$skip" = 0 ] || continue + n=$((n + 1)) + if [ "$status" != completed ]; then + echo "NOT FINISHED: ${name} (${status})"; bad=1 + else + case "$conclusion" in + success|skipped|neutral) [ "$name" != ci-ok ] || [ "$conclusion" != success ] || cios=1 ;; + *) echo "NOT GREEN: ${name} (${conclusion:-no conclusion})"; bad=1 ;; + esac + fi + done < "$RUNS" + [ "$n" -gt 0 ] || die "the tagged commit has no check runs: tag a commit that main's CI ran on" + [ "$cios" = 1 ] || die "Guard's ci-ok did not succeed on the tagged commit: tag a commit that main's CI ran on" + [ "$bad" = 0 ] || die "the tagged commit's checks are not all green: wait for them or fix main, then run the release again" + echo "OK: all ${n} check runs of the tagged commit finished green" + ;; + declared) + declared=$(manifest_version < "${ROOT}/nextflow.config") + [[ "v${declared}" =~ $semver ]] || die "nextflow.config declares version '${declared}', which is not MAJOR.MINOR.PATCH" + patch=${BASH_REMATCH[3]} + changelog="" + [ ! -f "${ROOT}/CHANGELOG.md" ] || changelog=$(cat "${ROOT}/CHANGELOG.md") + shopt -s nullglob + count=0 + for f in "${ROOT}"/docs/releases/*; do + name=$(basename "$f") + check_notes "$name" "$(cat "$f")" + grep -qF "docs/releases/${name}" <<< "$changelog" || die "CHANGELOG.md does not link docs/releases/${name}" + count=$((count + 1)) + done + if [ "$patch" = 0 ] && [ ! -f "${ROOT}/docs/releases/v${declared}.md" ]; then + die "nextflow.config declares ${declared}, a major or minor release, and docs/releases/v${declared}.md does not exist: write its notes in the same pull request" + fi + if [ -f "${ROOT}/docs/releases/v${declared}.md" ] && grep -q "$draft" "${ROOT}/docs/releases/v${declared}.md"; then + die "nextflow.config declares ${declared} and docs/releases/v${declared}.md still carries the 'Not released yet' notice: remove it in the same pull request" + fi + echo "OK: declared version ${declared}, ${count} notes file(s) well formed" + ;; + *) + die "usage: $0 [--repo DIR] {tag TAG [--notes-out FILE] | checks RUNS.TSV [--ignore NAME]... | declared}" + ;; +esac diff --git a/tests/test_release.sh b/tests/test_release.sh new file mode 100644 index 00000000..35ca667f --- /dev/null +++ b/tests/test_release.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# scripts/ci/check-release.sh on planted trees, then on the real one. +# tag: a tag that differs from manifest.version fails (the state main +# was in before the release check: 0.8.2 declared, v1.0.0 pushed); +# a minor tag without docs/releases/.md fails; with it, the +# notes file is the release body; a patch tag without notes gets +# the commit subjects; notes still carrying the 'Not released +# yet' notice fail; a missing tag fails. +# checks: a run still going, a failed or cancelled run, no ci-ok, or no +# runs at all fail; the ignored release job itself does not count. +# declared: a declared X.Y.0 without notes, notes with the wrong heading, +# a wrong file name, no CHANGELOG link, or the draft notice +# on the declared version's notes fail. +set -uo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +CHECK="${REPO}/scripts/ci/check-release.sh" +T=$(mktemp -d) +trap 'rm -rf "$T"' EXIT +fails=0 +ok() { echo "PASS $*"; } +bad() { echo "FAIL $*"; fails=$((fails + 1)); } +# expect RC WHAT ARGS...: run the check, compare its exit code (0 or non-0). +expect() { + local want=$1 what=$2 rc=0 + shift 2 + out=$(bash "$CHECK" "$@" 2>&1) || rc=$? + if { [ "$want" = 0 ] && [ "$rc" = 0 ]; } || { [ "$want" != 0 ] && [ "$rc" != 0 ]; }; then + ok "$what" + else + bad "$what (exit ${rc}): ${out}" + fi +} + +# cause TEXT: the last refusal names its cause, so an unrelated failure cannot pass. +cause() { grep -qF -- "$1" <<< "$out" || bad "the refusal names its cause ('$1'): ${out}"; } +g() { git -C "$T" -c user.name=t -c user.email=t@example.invalid -c tag.gpgSign=false -c commit.gpgSign=false -c core.hooksPath=/dev/null "$@"; } +config() { + cat > "${T}/nextflow.config" < + version = 'not this one' +} +manifest { + name = 'OwnGenome' + nextflowVersion = '!>=25.10.4' + version = '$1' +} +NF +} +notes() { + mkdir -p "${T}/docs/releases" + printf '# OwnGenome %s\n\nWhat you get.\n\n- one thing\n' "$1" > "${T}/docs/releases/v$1.md" +} +g init -q +config 0.8.2 +g add -A && g commit -qm "feat: the last commit before the cut" +g tag v0.8.2 +g commit -q --allow-empty -m "fix: a change after 0.8.2" +g tag v1.0.0 + +draft() { printf '# OwnGenome %s\n\n> **Not released yet.** Work still landing.\n\n- one thing\n' "$1" > "${T}/docs/releases/v$1.md"; } +# --- tag --- +expect 1 "tag v1.0.0 on a commit that declares 0.8.2 is refused" --repo "$T" tag v1.0.0 +grep -q "does not match the version" <<< "$out" || bad "the mismatch message names the cause: ${out}" +expect 1 "a tag that does not exist is refused" --repo "$T" tag v9.9.9 +cause "does not exist" +expect 1 "a tag not of the form vX.Y.Z is refused" --repo "$T" tag 1.0.0 +cause "not of the form" + +config 1.0.0 +g add -A && g commit -qm "chore: declare 1.0.0" +g tag -f v1.0.0 >/dev/null +expect 1 "minor tag v1.0.0 without docs/releases/v1.0.0.md is refused" --repo "$T" tag v1.0.0 +grep -q "major or minor release" <<< "$out" || bad "the missing-notes message names the cause: ${out}" + +notes 1.0.0 +g add -A && g commit -qm "docs: the 1.0.0 notes" +g tag -f v1.0.0 >/dev/null +expect 0 "v1.0.0 with matching manifest.version and its notes passes" --repo "$T" tag v1.0.0 --notes-out "${T}/body.md" +if cmp -s "${T}/body.md" "${T}/docs/releases/v1.0.0.md"; then ok "the release body is the notes file"; else bad "the release body is not the notes file"; fi +g tag -f -a -m "OwnGenome 1.0.0" v1.0.0 >/dev/null +expect 0 "an annotated v1.0.0 tag with matching manifest.version passes" --repo "$T" tag v1.0.0 +g tag -a -m "OwnGenome 1.1.0" v1.1.0 +expect 1 "an annotated v1.1.0 tag on a commit that declares 1.0.0 is refused" --repo "$T" tag v1.1.0 +cause "does not match the version" +g tag -d v1.1.0 >/dev/null + +draft 1.0.0 +g add -A && g commit -qm "docs: the notes go back to draft" +g tag -f v1.0.0 >/dev/null +expect 1 "notes that still carry the 'Not released yet' notice are refused" --repo "$T" tag v1.0.0 +grep -q "Not released yet" <<< "$out" || bad "the draft message names the cause: ${out}" + +: > "${T}/docs/releases/v1.0.0.md"; printf '# OwnGenome 1.0.0\n' > "${T}/docs/releases/v1.0.0.md" +g add -A && g commit -qm "docs: empty the notes" +g tag -f -a -m "OwnGenome 1.0.0" v1.0.0 >/dev/null # annotated: the patch body below must stop at it too +expect 1 "notes that hold only a heading are refused" --repo "$T" tag v1.0.0 +cause "only a heading" + +config 1.0.1 +g add -A && g commit -qm "fix: a patch after 1.0.0" +g tag v1.0.1 +expect 0 "patch tag v1.0.1 without notes passes" --repo "$T" tag v1.0.1 --notes-out "${T}/body.md" +if grep -qx -- "- fix: a patch after 1.0.0" "${T}/body.md"; then ok "a patch release body lists the commits since the previous tag" +else bad "patch body: $(cat "${T}/body.md")"; fi +if grep -q "a change after 0.8.2" "${T}/body.md"; then bad "the patch body reaches past the previous tag"; else ok "the patch body stops at the previous tag"; fi + +# --- checks --- +tab=$'\t' +runs() { printf '%s\n' "$@" > "${T}/runs.tsv"; } +runs "ci-ok${tab}completed${tab}success" "shellcheck${tab}completed${tab}success" "deploy${tab}completed${tab}skipped" \ + "create-release${tab}in_progress${tab}" +expect 0 "all checks green, the release job itself left out" checks "${T}/runs.tsv" --ignore create-release +expect 1 "the release job counts when it is not left out" checks "${T}/runs.tsv" +cause "NOT FINISHED: create-release" +runs "ci-ok${tab}completed${tab}success" "nextflow (pinned)${tab}in_progress${tab}" +expect 1 "a check still running is refused" checks "${T}/runs.tsv" +cause "NOT FINISHED" +runs "ci-ok${tab}completed${tab}success" "unit-tests${tab}completed${tab}failure" +expect 1 "a failed check is refused" checks "${T}/runs.tsv" +cause "NOT GREEN: unit-tests" +runs "ci-ok${tab}completed${tab}success" "e2e${tab}completed${tab}cancelled" +expect 1 "a cancelled check is refused" checks "${T}/runs.tsv" +cause "NOT GREEN: e2e" +runs "shellcheck${tab}completed${tab}success" +expect 1 "no ci-ok among the checks is refused" checks "${T}/runs.tsv" +cause "ci-ok did not succeed" +runs "ci-ok${tab}completed${tab}failure" +expect 1 "a failed ci-ok is refused" checks "${T}/runs.tsv" +cause "ci-ok did not succeed" +: > "${T}/runs.tsv" +expect 1 "a commit with no check runs is refused" checks "${T}/runs.tsv" +cause "no check runs" + +# --- declared --- +D="${T}/declared" +mkdir -p "${D}/docs/releases" +cat > "${D}/nextflow.config" <<'NF' +manifest { + version = '1.0.0' +} +NF +echo "# Changelog" > "${D}/CHANGELOG.md" +expect 1 "declared 1.0.0 without its notes is refused" --repo "$D" declared +cause "does not exist: write its notes" +printf '# OwnGenome 1.0.0\n\n- a thing\n' > "${D}/docs/releases/v1.0.0.md" +expect 1 "notes that CHANGELOG.md does not link are refused" --repo "$D" declared +cause "CHANGELOG.md does not link" +echo "- 1.0.0: docs/releases/v1.0.0.md" >> "${D}/CHANGELOG.md" +expect 0 "declared 1.0.0 with linked notes passes" --repo "$D" declared +printf '# OwnGenome 1.0.0\n\n> **Not released yet.** Work still landing.\n\n- a thing\n' > "${D}/docs/releases/v1.0.0.md" +expect 1 "declared 1.0.0 whose notes still carry the draft notice is refused" --repo "$D" declared +cause "Not released yet" +sed -i.bak "s/'1.0.0'/'0.9.9'/" "${D}/nextflow.config" +expect 0 "draft notes for a version not declared yet pass" --repo "$D" declared +sed -i.bak "s/'0.9.9'/'1.0.0'/" "${D}/nextflow.config" +printf '# OwnGenome 1.0.0\n\n- a thing\n' > "${D}/docs/releases/v1.0.0.md" +printf '# Release 1.0.0\n\n- a thing\n' > "${D}/docs/releases/v1.0.0.md" +expect 1 "notes whose heading is not '# OwnGenome 1.0.0' are refused" --repo "$D" declared +cause "the first line must be" +printf '# OwnGenome 1.0.0\n\n- a thing\n' > "${D}/docs/releases/v1.0.0.md" +printf '# OwnGenome 1.1\n\n- a thing\n' > "${D}/docs/releases/v1.1.md" +expect 1 "a notes file not named vX.Y.Z.md is refused" --repo "$D" declared +cause "is named vMAJOR.MINOR.PATCH.md" +rm "${D}/docs/releases/v1.1.md" +sed -i.bak "s/'1.0.0'/'1.0.2'/" "${D}/nextflow.config" +expect 0 "a declared patch version needs no notes of its own" --repo "$D" declared +printf 'manifest {\n name = "x"\n}\n' > "${D}/nextflow.config" +expect 1 "a manifest with no version is refused" --repo "$D" declared +cause "declares no version" + +# --- the real tree --- +expect 0 "the real tree: declared version and every notes file" declared +echo "$out" + +[ "$fails" -eq 0 ] || { echo "${fails} release check case(s) failed"; exit 1; } +echo "All release check cases passed" diff --git a/tests/test_release_report.py b/tests/test_release_report.py new file mode 100644 index 00000000..8dd4adda --- /dev/null +++ b/tests/test_release_report.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""The OwnGenome release in both reports (bin/render_report.py). + +A run_manifest.tsv with a `run pipeline_version` row puts "OwnGenome " +next to the pipeline commit in the text report and in the HTML header. Red +control: the same manifest without the row prints no release, so the check +sees the row and not some other text. +""" +import os +import subprocess +import sys +import tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RENDER = os.path.join(ROOT, "bin", "render_report.py") + + +def render(rows): + with tempfile.TemporaryDirectory() as d: + sample = os.path.join(d, "S1") + os.makedirs(sample) + with open(os.path.join(sample, "run_manifest.tsv"), "w") as f: + for r in rows: + f.write("\t".join(r) + "\n") + txt, html = os.path.join(d, "r.txt"), os.path.join(d, "r.html") + subprocess.run([sys.executable, RENDER, "--sample", "S1", "--sample-dir", sample, + "--json", os.path.join(d, "summary.json"), "-o", txt, "-o", html], + check=True, stdout=subprocess.DEVNULL) + with open(txt) as a, open(html) as b: + return a.read(), b.read() + + +base = [("run", "written_utc", "2026-11-06 12:00:00 UTC"), ("run", "written_by", "run-all.sh"), + ("run", "git_commit", "0123456789abcdef0123456789abcdef01234567")] +fails = 0 + + +def check(ok, what): + global fails + print(("PASS " if ok else "FAIL ") + what) + fails += not ok + + +txt, html = render(base + [("run", "pipeline_version", "1.0.0")]) +check("(OwnGenome 1.0.0, pipeline commit 0123456789abcdef0123456789abcdef01234567," in txt, + "the text report names the release next to the commit") +check("OwnGenome release: 1.0.0  | " in html and "Pipeline commit: 0123456789abcdef" in html, + "the HTML header names the release next to the commit") + +txt, html = render(base) +check("OwnGenome 1." not in txt and "(pipeline commit 0123456789abcdef" in txt, + "red control: without the row the text report names no release") +check("OwnGenome release:" not in html, "red control: without the row the HTML header names no release") + +sys.exit(1 if fails else 0) diff --git a/zensical.toml b/zensical.toml index 52689772..fb114058 100644 --- a/zensical.toml +++ b/zensical.toml @@ -15,11 +15,14 @@ repo_name = "GeiserX/OwnGenome" edit_uri = "edit/main/docs/" strict = true extra_css = ["stylesheets/theme.css"] +# releases/ keeps release notes off the site until their release is tagged; the +# cut moves the page from exclude_docs into the nav. exclude_docs = """ [A-Z]*.md design/ research/ notes/ +releases/ requirements-docs.txt """ nav = [