Merge branch 'releases/Release_2_11_3_Branch'
[jalview.git] / build.gradle
index 34a3461..3b81404 100644 (file)
@@ -9,9 +9,15 @@ import org.gradle.util.ConfigureUtil
 import org.gradle.plugins.ide.eclipse.model.Output
 import org.gradle.plugins.ide.eclipse.model.Library
 import java.security.MessageDigest
+import java.util.regex.Matcher
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
+import java.util.concurrent.ScheduledExecutorService
+import java.util.concurrent.TimeUnit
 import groovy.transform.ExternalizeMethods
 import groovy.util.XmlParser
 import groovy.xml.XmlUtil
+import groovy.json.JsonBuilder
 import com.vladsch.flexmark.util.ast.Node
 import com.vladsch.flexmark.html.HtmlRenderer
 import com.vladsch.flexmark.parser.Parser
@@ -22,6 +28,11 @@ import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension
 import com.vladsch.flexmark.ext.autolink.AutolinkExtension
 import com.vladsch.flexmark.ext.anchorlink.AnchorLinkExtension
 import com.vladsch.flexmark.ext.toc.TocExtension
+import com.google.common.hash.HashCode
+import com.google.common.hash.Hashing
+import com.google.common.io.Files
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Element
 
 buildscript {
   repositories {
@@ -30,6 +41,8 @@ buildscript {
   }
   dependencies {
     classpath "com.vladsch.flexmark:flexmark-all:0.62.0"
+    classpath "org.jsoup:jsoup:1.14.3"
+    classpath "com.eowise:gradle-imagemagick:0.5.1"
   }
 }
 
@@ -39,10 +52,10 @@ plugins {
   id 'application'
   id 'eclipse'
   id "com.diffplug.gradle.spotless" version "3.28.0"
-  id 'com.github.johnrengelman.shadow' version '4.0.3'
-  id 'com.install4j.gradle' version '8.0.10'
-  id 'com.dorongold.task-tree' version '1.5' // only needed to display task dependency tree with  gradle task1 [task2 ...] taskTree
-  id 'com.palantir.git-version' version '0.12.3'
+  id 'com.github.johnrengelman.shadow' version '6.0.0'
+  id 'com.install4j.gradle' version '10.0.3'
+  id 'com.dorongold.task-tree' version '2.1.1' // only needed to display task dependency tree with  gradle task1 [task2 ...] taskTree
+  id 'com.palantir.git-version' version '0.13.0' apply false
 }
 
 repositories {
@@ -96,12 +109,17 @@ def overrideProperties(String propsFileName, boolean output = false) {
 ext {
   jalviewDirAbsolutePath = file(jalviewDir).getAbsolutePath()
   jalviewDirRelativePath = jalviewDir
+  date = new Date()
 
   getdownChannelName = CHANNEL.toLowerCase()
   // default to "default". Currently only has different cosmetics for "develop", "release", "default"
   propertiesChannelName = ["develop", "release", "test-release", "jalviewjs", "jalviewjs-release" ].contains(getdownChannelName) ? getdownChannelName : "default"
+  channelDirName = propertiesChannelName
   // Import channel_properties
-  channelDir = string("${jalviewDir}/${channel_properties_dir}/${propertiesChannelName}")
+  if (getdownChannelName.startsWith("develop-")) {
+    channelDirName = "develop-SUFFIX"
+  }
+  channelDir = string("${jalviewDir}/${channel_properties_dir}/${channelDirName}")
   channelGradleProperties = string("${channelDir}/channel_gradle.properties")
   channelPropsFile = string("${channelDir}/${resource_dir}/${channel_props}")
   overrideProperties(channelGradleProperties, false)
@@ -115,7 +133,7 @@ ext {
   // Import releaseProps from the RELEASE file
   // or a file specified via JALVIEW_RELEASE_FILE if defined
   // Expect jalview.version and target release branch in jalview.release        
-  def releaseProps = new Properties();
+  releaseProps = new Properties();
   def releasePropFile = findProperty("JALVIEW_RELEASE_FILE");
   def defaultReleasePropFile = "${jalviewDirAbsolutePath}/RELEASE";
   try {
@@ -130,6 +148,7 @@ ext {
   if (findProperty("JALVIEW_VERSION")==null || "".equals(JALVIEW_VERSION)) {
     JALVIEW_VERSION = releaseProps.get("jalview.version")
   }
+  println("JALVIEW_VERSION is set to '${JALVIEW_VERSION}'")
   
   // this property set when running Eclipse headlessly
   j2sHeadlessBuildProperty = string("net.sf.j2s.core.headlessbuild")
@@ -189,22 +208,34 @@ ext {
   testSourceDir = useClover ? cloverTestInstrDir : testDir
   testClassesDir = useClover ? cloverTestClassesDir : "${jalviewDir}/${test_output_dir}"
 
-  getdownWebsiteDir = string("${jalviewDir}/${getdown_website_dir}/${JAVA_VERSION}")
+  channelSuffix = ""
+  backgroundImageText = BACKGROUNDIMAGETEXT
+  getdownChannelDir = string("${getdown_website_dir}/${propertiesChannelName}")
+  getdownAppBaseDir = string("${jalviewDir}/${getdownChannelDir}/${JAVA_VERSION}")
+  getdownArchiveDir = string("${jalviewDir}/${getdown_archive_dir}")
+  getdownFullArchiveDir = null
+  getdownTextLines = []
+  getdownLaunchJvl = null
+  getdownVersionLaunchJvl = null
   buildDist = true
   buildProperties = null
 
   // the following values might be overridden by the CHANNEL switch
   getdownDir = string("${getdownChannelName}/${JAVA_VERSION}")
   getdownAppBase = string("${getdown_channel_base}/${getdownDir}")
+  getdownArchiveAppBase = getdown_archive_base
   getdownLauncher = string("${jalviewDir}/${getdown_lib_dir}/${getdown_launcher}")
   getdownAppDistDir = getdown_app_dir_alt
   getdownImagesDir = string("${jalviewDir}/${getdown_images_dir}")
+  getdownImagesBuildDir = string("${buildDir}/imagemagick/getdown")
   getdownSetAppBaseProperty = false // whether to pass the appbase and appdistdir to the application
   reportRsyncCommand = false
   jvlChannelName = CHANNEL.toLowerCase()
   install4jSuffix = CHANNEL.substring(0, 1).toUpperCase() + CHANNEL.substring(1).toLowerCase(); // BUILD -> Build
   install4jDMGDSStore = "${install4j_images_dir}/${install4j_dmg_ds_store}"
-  install4jDMGBackgroundImage = "${install4j_images_dir}/${install4j_dmg_background}"
+  install4jDMGBackgroundImageDir = "${install4j_images_dir}"
+  install4jDMGBackgroundImageBuildDir = "build/imagemagick/install4j"
+  install4jDMGBackgroundImageFile = "${install4j_dmg_background}"
   install4jInstallerName = "${jalview_name} Non-Release Installer"
   install4jExecutableName = install4j_executable_name
   install4jExtraScheme = "jalviewx"
@@ -212,6 +243,10 @@ ext {
   install4jWindowsIconsFile = string("${install4j_images_dir}/${install4j_windows_icons_file}")
   install4jPngIconFile = string("${install4j_images_dir}/${install4j_png_icon_file}")
   install4jBackground = string("${install4j_images_dir}/${install4j_background}")
+  install4jBuildDir = "${install4j_build_dir}/${JAVA_VERSION}"
+  install4jCheckSums = true
+
+  applicationName = "${jalview_name}"
   switch (CHANNEL) {
 
     case "BUILD":
@@ -225,10 +260,12 @@ ext {
       testng_excluded_groups = "Not-bamboo"
     }
     install4jExtraScheme = "jalviewb"
+    backgroundImageText = true
     break
 
     case [ "RELEASE", "JALVIEWJS-RELEASE" ]:
     getdownAppDistDir = getdown_app_dir_release
+    getdownSetAppBaseProperty = true
     reportRsyncCommand = true
     install4jSuffix = ""
     install4jInstallerName = "${jalview_name} Installer"
@@ -252,9 +289,9 @@ ext {
     case "ARCHIVELOCAL":
     getdownChannelName = string("archive/${JALVIEW_VERSION}")
     getdownDir = string("${getdownChannelName}/${JAVA_VERSION}")
-    getdownAppBase = file(getdownWebsiteDir).toURI().toString()
+    getdownAppBase = file(getdownAppBaseDir).toURI().toString()
     if (!file("${ARCHIVEDIR}/${package_dir}").exists()) {
-      throw new GradleException("Must provide an ARCHIVEDIR value to produce an archive distribution")
+      throw new GradleException("Must provide an ARCHIVEDIR value to produce an archive distribution [did not find '${ARCHIVEDIR}/${package_dir}']")
     } else {
       package_dir = string("${ARCHIVEDIR}/${package_dir}")
       buildProperties = string("${ARCHIVEDIR}/${classes_dir}/${build_properties_file}")
@@ -266,6 +303,23 @@ ext {
     install4jExtraScheme = "jalviewa"
     break
 
+    case ~/^DEVELOP-([\.\-\w]*)$/:
+    def suffix = Matcher.lastMatcher[0][1]
+    reportRsyncCommand = true
+    getdownSetAppBaseProperty = true
+    JALVIEW_VERSION=JALVIEW_VERSION+"-d${suffix}-${buildDate}"
+    install4jSuffix = "Develop ${suffix}"
+    install4jExtraScheme = "jalviewd"
+    install4jInstallerName = "${jalview_name} Develop ${suffix} Installer"
+    getdownChannelName = string("develop-${suffix}")
+    getdownChannelDir = string("${getdown_website_dir}/${getdownChannelName}")
+    getdownAppBaseDir = string("${jalviewDir}/${getdownChannelDir}/${JAVA_VERSION}")
+    getdownDir = string("${getdownChannelName}/${JAVA_VERSION}")
+    getdownAppBase = string("${getdown_channel_base}/${getdownDir}")
+    channelSuffix = string(suffix)
+    backgroundImageText = true
+    break
+
     case "DEVELOP":
     reportRsyncCommand = true
     getdownSetAppBaseProperty = true
@@ -275,10 +329,12 @@ ext {
     install4jSuffix = "Develop"
     install4jExtraScheme = "jalviewd"
     install4jInstallerName = "${jalview_name} Develop Installer"
+    backgroundImageText = true
     break
 
     case "TEST-RELEASE":
     reportRsyncCommand = true
+    getdownSetAppBaseProperty = true
     // Don't ignore transpile errors for release build
     if (jalviewjs_ignore_transpile_errors.equals("true")) {
       jalviewjs_ignore_transpile_errors = "false"
@@ -288,6 +344,7 @@ ext {
     install4jSuffix = "Test"
     install4jExtraScheme = "jalviewt"
     install4jInstallerName = "${jalview_name} Test Installer"
+    backgroundImageText = true
     break
 
     case ~/^SCRATCH(|-[-\w]*)$/:
@@ -311,13 +368,16 @@ ext {
     install4jSuffix = "Test-Local"
     install4jExtraScheme = "jalviewt"
     install4jInstallerName = "${jalview_name} Test Installer"
+    backgroundImageText = true
     break
 
     case [ "LOCAL", "JALVIEWJS" ]:
     JALVIEW_VERSION = "TEST"
-    getdownAppBase = file(getdownWebsiteDir).toURI().toString()
+    getdownAppBase = file(getdownAppBaseDir).toURI().toString()
+    getdownArchiveAppBase = file("${jalviewDir}/${getdown_archive_dir}").toURI().toString()
     getdownLauncher = string("${jalviewDir}/${getdown_lib_dir}/${getdown_launcher_local}")
     install4jExtraScheme = "jalviewl"
+    install4jCheckSums = false
     break
 
     default: // something wrong specified
@@ -325,11 +385,14 @@ ext {
     break
 
   }
+  JALVIEW_VERSION_UNDERSCORES = JALVIEW_VERSION.replaceAll("\\.", "_")
+  hugoDataJsonFile = file("${jalviewDir}/${hugo_build_dir}/${hugo_data_installers_dir}/installers-${JALVIEW_VERSION_UNDERSCORES}.json")
+  hugoArchiveMdFile = file("${jalviewDir}/${hugo_build_dir}/${hugo_version_archive_dir}/Version-${JALVIEW_VERSION_UNDERSCORES}/_index.md")
   // override getdownAppBase if requested
   if (findProperty("getdown_appbase_override") != null) {
     // revert to LOCAL if empty string
     if (string(getdown_appbase_override) == "") {
-      getdownAppBase = file(getdownWebsiteDir).toURI().toString()
+      getdownAppBase = file(getdownAppBaseDir).toURI().toString()
       getdownLauncher = string("${jalviewDir}/${getdown_lib_dir}/${getdown_launcher_local}")
     } else if (string(getdown_appbase_override).startsWith("file://")) {
       getdownAppBase = string(getdown_appbase_override)
@@ -343,11 +406,10 @@ ext {
   jvlChannelName = jvlChannelName.replaceAll("[^\\w\\-]+", "_")
   // install4j application and folder names
   if (install4jSuffix == "") {
-    install4jApplicationName = "${jalview_name}"
     install4jBundleId = "${install4j_bundle_id}"
     install4jWinApplicationId = install4j_release_win_application_id
   } else {
-    install4jApplicationName = "${jalview_name} ${install4jSuffix}"
+    applicationName = "${jalview_name} ${install4jSuffix}"
     install4jBundleId = "${install4j_bundle_id}-" + install4jSuffix.toLowerCase()
     // add int hash of install4jSuffix to the last part of the application_id
     def id = install4j_release_win_application_id
@@ -357,15 +419,15 @@ ext {
   }
   // sanitise folder and id names
   // install4jApplicationFolder = e.g. "Jalview Build"
-  install4jApplicationFolder = install4jApplicationName
+  install4jApplicationFolder = applicationName
                                     .replaceAll("[\"'~:/\\\\\\s]", "_") // replace all awkward filename chars " ' ~ : / \
                                     .replaceAll("_+", "_") // collapse __
-  install4jInternalId = install4jApplicationName
+  install4jInternalId = applicationName
                                     .replaceAll(" ","_")
                                     .replaceAll("[^\\w\\-\\.]", "_") // replace other non [alphanumeric,_,-,.]
                                     .replaceAll("_+", "") // collapse __
                                     //.replaceAll("_*-_*", "-") // collapse _-_
-  install4jUnixApplicationFolder = install4jApplicationName
+  install4jUnixApplicationFolder = applicationName
                                     .replaceAll(" ","_")
                                     .replaceAll("[^\\w\\-\\.]", "_") // replace other non [alphanumeric,_,-,.]
                                     .replaceAll("_+", "_") // collapse __
@@ -373,19 +435,31 @@ ext {
                                     .toLowerCase()
 
   getdownWrapperLink = install4jUnixApplicationFolder // e.g. "jalview_local"
-  getdownAppDir = string("${getdownWebsiteDir}/${getdownAppDistDir}")
-  //getdownJ11libDir = "${getdownWebsiteDir}/${getdown_j11lib_dir}"
-  getdownResourceDir = string("${getdownWebsiteDir}/${getdown_resource_dir}")
-  getdownInstallDir = string("${getdownWebsiteDir}/${getdown_install_dir}")
+  getdownAppDir = string("${getdownAppBaseDir}/${getdownAppDistDir}")
+  //getdownJ11libDir = "${getdownAppBaseDir}/${getdown_j11lib_dir}"
+  getdownResourceDir = string("${getdownAppBaseDir}/${getdown_resource_dir}")
+  getdownInstallDir = string("${getdownAppBaseDir}/${getdown_install_dir}")
   getdownFilesDir = string("${jalviewDir}/${getdown_files_dir}/${JAVA_VERSION}/")
   getdownFilesInstallDir = string("${getdownFilesDir}/${getdown_install_dir}")
   /* compile without modules -- using classpath libraries
   modules_compileClasspath = fileTree(dir: "${jalviewDir}/${j11modDir}", include: ["*.jar"])
   modules_runtimeClasspath = modules_compileClasspath
   */
-  def details = versionDetails()
-  gitHash = details.gitHash
-  gitBranch = details.branchName
+
+  gitHash = "SOURCE"
+  gitBranch = "Source"
+  try {
+    apply plugin: "com.palantir.git-version"
+    def details = versionDetails()
+    gitHash = details.gitHash
+    gitBranch = details.branchName
+  } catch(org.gradle.api.internal.plugins.PluginApplicationException e) {
+    println("Not in a git repository. Using git values from RELEASE properties file.")
+    gitHash = releaseProps.getProperty("git.hash")
+    gitBranch = releaseProps.getProperty("git.branch")
+  } catch(java.lang.RuntimeException e1) {
+    throw new GradleException("Error with git-version plugin.  Directory '.git' exists but versionDetails() cannot be found.")
+  }
 
   println("Using a ${CHANNEL} profile.")
 
@@ -445,16 +519,10 @@ ext {
   // for install4j
   JAVA_MIN_VERSION = JAVA_VERSION
   JAVA_MAX_VERSION = JAVA_VERSION
-  def jreInstallsDir = string(jre_installs_dir)
+  jreInstallsDir = string(jre_installs_dir)
   if (jreInstallsDir.startsWith("~/")) {
     jreInstallsDir = System.getProperty("user.home") + jreInstallsDir.substring(1)
   }
-  macosJavaVMDir = string("${jreInstallsDir}/jre-${JAVA_INTEGER_VERSION}-mac-x64/jre")
-  macosJavaVMTgz = string("${jreInstallsDir}/tgz/jre-${JAVA_INTEGER_VERSION}-mac-x64.tar.gz")
-  windowsJavaVMDir = string("${jreInstallsDir}/jre-${JAVA_INTEGER_VERSION}-windows-x64/jre")
-  windowsJavaVMTgz = string("${jreInstallsDir}/tgz/jre-${JAVA_INTEGER_VERSION}-windows-x64.tar.gz")
-  linuxJavaVMDir = string("${jreInstallsDir}/jre-${JAVA_INTEGER_VERSION}-linux-x64/jre")
-  linuxJavaVMTgz = string("${jreInstallsDir}/tgz/jre-${JAVA_INTEGER_VERSION}-linux-x64.tar.gz")
   install4jDir = string("${jalviewDir}/${install4j_utils_dir}")
   install4jConfFileName = string("jalview-install4j-conf.install4j")
   install4jConfFile = file("${install4jDir}/${install4jConfFileName}")
@@ -476,6 +544,14 @@ ext {
   helpSourceDir = string("${helpParentDir}/${help_dir}")
   helpFile = string("${helpBuildDir}/${help_dir}/help.jhm")
 
+  convertBinary = null
+  convertBinaryExpectedLocation = imagemagick_convert
+  if (convertBinaryExpectedLocation.startsWith("~/")) {
+    convertBinaryExpectedLocation = System.getProperty("user.home") + convertBinaryExpectedLocation.substring(1)
+  }
+  if (file(convertBinaryExpectedLocation).exists()) {
+    convertBinary = convertBinaryExpectedLocation
+  }
 
   relativeBuildDir = file(jalviewDirAbsolutePath).toPath().relativize(buildDir.toPath())
   jalviewjsBuildDir = string("${relativeBuildDir}/jalviewjs")
@@ -496,11 +572,16 @@ ext {
   jalviewjsJ2sAltSettingsFileName = string("${jalviewDir}/${jalviewjs_j2s_alt_settings}")
   jalviewjsJ2sProps = null
   jalviewjsJ2sPlugin = jalviewjs_j2s_plugin
+  jalviewjsStderrLaunchFilename = "${jalviewjsSiteDir}/"+(file(jalviewjs_stderr_launch).getName())
 
   eclipseWorkspace = null
   eclipseBinary = string("")
   eclipseVersion = string("")
   eclipseDebug = false
+
+  jalviewjsChromiumUserDir = "${jalviewjsBuildDir}/${jalviewjs_chromium_user_dir}"
+  jalviewjsChromiumProfileDir = "${ext.jalviewjsChromiumUserDir}/${jalviewjs_chromium_profile_name}"
+
   // ENDEXT
 }
 
@@ -992,7 +1073,7 @@ compileJava {
   // JBP->BS should the print statement in doFirst refer to compile_target_compatibility ?
   sourceCompatibility = compile_source_compatibility
   targetCompatibility = compile_target_compatibility
-  options.compilerArgs = additional_compiler_args
+  options.compilerArgs += additional_compiler_args
   options.encoding = "UTF-8"
   doFirst {
     print ("Setting target compatibility to "+compile_target_compatibility+"\n")
@@ -1004,7 +1085,7 @@ compileJava {
 compileTestJava {
   sourceCompatibility = compile_source_compatibility
   targetCompatibility = compile_target_compatibility
-  options.compilerArgs = additional_compiler_args
+  options.compilerArgs += additional_compiler_args
   doFirst {
     print ("Setting target compatibility to "+targetCompatibility+"\n")
   }
@@ -1028,7 +1109,6 @@ cleanTest {
 
 // format is a string like date.format("dd MMMM yyyy")
 def getDate(format) {
-  def date = new Date()
   return date.format(format)
 }
 
@@ -1163,6 +1243,214 @@ task convertMdFiles {
 }
 
 
+def hugoTemplateSubstitutions(String input, Map extras=null) {
+  def replacements = [
+    DATE: getDate("yyyy-MM-dd"),
+    CHANNEL: propertiesChannelName,
+    APPLICATION_NAME: applicationName,
+    GIT_HASH: gitHash,
+    GIT_BRANCH: gitBranch,
+    VERSION: JALVIEW_VERSION,
+    JAVA_VERSION: JAVA_VERSION,
+    VERSION_UNDERSCORES: JALVIEW_VERSION_UNDERSCORES,
+    DRAFT: "false",
+    JVL_HEADER: ""
+  ]
+  def output = input
+  if (extras != null) {
+    extras.each{ k, v ->
+      output = output.replaceAll("__${k}__", ((v == null)?"":v))
+    }
+  }
+  replacements.each{ k, v ->
+    output = output.replaceAll("__${k}__", ((v == null)?"":v))
+  }
+  return output
+}
+
+def mdFileComponents(File mdFile, def dateOnly=false) {
+  def map = [:]
+  def content = ""
+  if (mdFile.exists()) {
+    def inFrontMatter = false
+    def firstLine = true
+    mdFile.eachLine { line ->
+      if (line.matches("---")) {
+        def prev = inFrontMatter
+        inFrontMatter = firstLine
+        if (inFrontMatter != prev)
+          return false
+      }
+      if (inFrontMatter) {
+        def m = null
+        if (m = line =~ /^date:\s*(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/) {
+          map["date"] = new Date().parse("yyyy-MM-dd HH:mm:ss", m[0][1])
+        } else if (m = line =~ /^date:\s*(\d{4}-\d{2}-\d{2})/) {
+          map["date"] = new Date().parse("yyyy-MM-dd", m[0][1])
+        } else if (m = line =~ /^channel:\s*(\S+)/) {
+          map["channel"] = m[0][1]
+        } else if (m = line =~ /^version:\s*(\S+)/) {
+          map["version"] = m[0][1]
+        } else if (m = line =~ /^\s*([^:]+)\s*:\s*(\S.*)/) {
+          map[ m[0][1] ] = m[0][2]
+        }
+        if (dateOnly && map["date"] != null) {
+          return false
+        }
+      } else {
+        if (dateOnly)
+          return false
+        content += line+"\n"
+      }
+      firstLine = false
+    }
+  }
+  return dateOnly ? map["date"] : [map, content]
+}
+
+task hugoTemplates {
+  group "website"
+  description "Create partially populated md pages for hugo website build"
+
+  def hugoTemplatesDir = file("${jalviewDir}/${hugo_templates_dir}")
+  def hugoBuildDir = "${jalviewDir}/${hugo_build_dir}"
+  def templateFiles = fileTree(dir: hugoTemplatesDir)
+  def releaseMdFile = file("${jalviewDir}/${releases_dir}/release-${JALVIEW_VERSION_UNDERSCORES}.md")
+  def whatsnewMdFile = file("${jalviewDir}/${whatsnew_dir}/whatsnew-${JALVIEW_VERSION_UNDERSCORES}.md")
+  def oldJvlFile = file("${jalviewDir}/${hugo_old_jvl}")
+  def jalviewjsFile = file("${jalviewDir}/${hugo_jalviewjs}")
+
+  doFirst {
+    // specific release template for version archive
+    def changes = ""
+    def whatsnew = null
+    def givenDate = null
+    def givenChannel = null
+    def givenVersion = null
+    if (CHANNEL == "RELEASE") {
+      def (map, content) = mdFileComponents(releaseMdFile)
+      givenDate = map.date
+      givenChannel = map.channel
+      givenVersion = map.version
+      changes = content
+      if (givenVersion != null && givenVersion != JALVIEW_VERSION) {
+        throw new GradleException("'version' header (${givenVersion}) found in ${releaseMdFile} does not match JALVIEW_VERSION (${JALVIEW_VERSION})")
+      }
+
+      if (whatsnewMdFile.exists())
+        whatsnew = whatsnewMdFile.text
+    }
+
+    def oldJvl = oldJvlFile.exists() ? oldJvlFile.collect{it} : []
+    def jalviewjsLink = jalviewjsFile.exists() ? jalviewjsFile.collect{it} : []
+
+    def changesHugo = null
+    if (changes != null) {
+      changesHugo = '<div class="release_notes">\n\n'
+      def inSection = false
+      changes.eachLine { line ->
+        def m = null
+        if (m = line =~ /^##([^#].*)$/) {
+          if (inSection) {
+            changesHugo += "</div>\n\n"
+          }
+          def section = m[0][1].trim()
+          section = section.toLowerCase()
+          section = section.replaceAll(/ +/, "_")
+          section = section.replaceAll(/[^a-z0-9_\-]/, "")
+          changesHugo += "<div class=\"${section}\">\n\n"
+          inSection = true
+        } else if (m = line =~ /^(\s*-\s*)<!--([^>]+)-->(.*?)(<br\/?>)?\s*$/) {
+          def comment = m[0][2].trim()
+          if (comment != "") {
+            comment = comment.replaceAll('"', "&quot;")
+            def issuekeys = []
+            comment.eachMatch(/JAL-\d+/) { jal -> issuekeys += jal }
+            def newline = m[0][1]
+            if (comment.trim() != "")
+              newline += "{{<comment>}}${comment}{{</comment>}}  "
+            newline += m[0][3].trim()
+            if (issuekeys.size() > 0)
+              newline += "  {{< jal issue=\"${issuekeys.join(",")}\" alt=\"${comment}\" >}}"
+            if (m[0][4] != null)
+              newline += m[0][4]
+            line = newline
+          }
+        }
+        changesHugo += line+"\n"
+      }
+      if (inSection) {
+        changesHugo += "\n</div>\n\n"
+      }
+      changesHugo += '</div>'
+    }
+
+    templateFiles.each{ templateFile ->
+      def newFileName = string(hugoTemplateSubstitutions(templateFile.getName()))
+      def relPath = hugoTemplatesDir.toPath().relativize(templateFile.toPath()).getParent()
+      def newRelPathName = hugoTemplateSubstitutions( relPath.toString() )
+
+      def outPathName = string("${hugoBuildDir}/$newRelPathName")
+
+      copy {
+        from templateFile
+        rename(templateFile.getName(), newFileName)
+        into outPathName
+      }
+
+      def newFile = file("${outPathName}/${newFileName}".toString())
+      def content = newFile.text
+      newFile.text = hugoTemplateSubstitutions(content,
+        [
+          WHATSNEW: whatsnew,
+          CHANGES: changesHugo,
+          DATE: givenDate == null ? "" : givenDate.format("yyyy-MM-dd"),
+          DRAFT: givenDate == null ? "true" : "false",
+          JALVIEWJSLINK: jalviewjsLink.contains(JALVIEW_VERSION) ? "true" : "false",
+          JVL_HEADER: oldJvl.contains(JALVIEW_VERSION) ? "jvl: true" : ""
+        ]
+      )
+    }
+
+  }
+
+  inputs.file(oldJvlFile)
+  inputs.dir(hugoTemplatesDir)
+  inputs.property("JALVIEW_VERSION", { JALVIEW_VERSION })
+  inputs.property("CHANNEL", { CHANNEL })
+}
+
+def getMdDate(File mdFile) {
+  return mdFileComponents(mdFile, true)
+}
+
+def getMdSections(String content) {
+  def sections = [:]
+  def sectionContent = ""
+  def sectionName = null
+  content.eachLine { line ->
+    def m = null
+    if (m = line =~ /^##([^#].*)$/) {
+      if (sectionName != null) {
+        sections[sectionName] = sectionContent
+        sectionName = null
+        sectionContent = ""
+      }
+      sectionName = m[0][1].trim()
+      sectionName = sectionName.toLowerCase()
+      sectionName = sectionName.replaceAll(/ +/, "_")
+      sectionName = sectionName.replaceAll(/[^a-z0-9_\-]/, "")
+    } else if (sectionName != null) {
+      sectionContent += line+"\n"
+    }
+  }
+  if (sectionContent != null) {
+    sections[sectionName] = sectionContent
+  }
+  return sections
+}
+
+
 task copyHelp(type: Copy) {
   def inputDir = helpSourceDir
   def outputDir = "${helpBuildDir}/${help_dir}"
@@ -1198,6 +1486,143 @@ task copyHelp(type: Copy) {
 }
 
 
+task releasesTemplates {
+  group "help"
+  description "Recreate whatsNew.html and releases.html from markdown files and templates in help"
+
+  dependsOn copyHelp
+
+  def releasesTemplateFile = file("${jalviewDir}/${releases_template}")
+  def whatsnewTemplateFile = file("${jalviewDir}/${whatsnew_template}")
+  def releasesHtmlFile = file("${helpBuildDir}/${help_dir}/${releases_html}")
+  def whatsnewHtmlFile = file("${helpBuildDir}/${help_dir}/${whatsnew_html}")
+  def releasesMdDir = "${jalviewDir}/${releases_dir}"
+  def whatsnewMdDir = "${jalviewDir}/${whatsnew_dir}"
+
+  doFirst {
+    def releaseMdFile = file("${releasesMdDir}/release-${JALVIEW_VERSION_UNDERSCORES}.md")
+    def whatsnewMdFile = file("${whatsnewMdDir}/whatsnew-${JALVIEW_VERSION_UNDERSCORES}.md")
+
+    if (CHANNEL == "RELEASE") {
+      if (!releaseMdFile.exists()) {
+        throw new GradleException("File ${releaseMdFile} must be created for RELEASE")
+      }
+      if (!whatsnewMdFile.exists()) {
+        throw new GradleException("File ${whatsnewMdFile} must be created for RELEASE")
+      }
+    }
+
+    def releaseFiles = fileTree(dir: releasesMdDir, include: "release-*.md")
+    def releaseFilesDates = releaseFiles.collectEntries {
+      [(it): getMdDate(it)]
+    }
+    releaseFiles = releaseFiles.sort { a,b -> releaseFilesDates[a].compareTo(releaseFilesDates[b]) }
+
+    def releasesTemplate = releasesTemplateFile.text
+    def m = releasesTemplate =~ /(?s)__VERSION_LOOP_START__(.*)__VERSION_LOOP_END__/
+    def versionTemplate = m[0][1]
+
+    MutableDataSet options = new MutableDataSet()
+
+    def extensions = new ArrayList<>()
+    options.set(Parser.EXTENSIONS, extensions)
+    options.set(Parser.HTML_BLOCK_COMMENT_ONLY_FULL_LINE, true)
+
+    Parser parser = Parser.builder(options).build()
+    HtmlRenderer renderer = HtmlRenderer.builder(options).build()
+
+    def actualVersions = releaseFiles.collect { rf ->
+      def (rfMap, rfContent) = mdFileComponents(rf)
+      return rfMap.version
+    }
+    def versionsHtml = ""
+    def linkedVersions = []
+    releaseFiles.reverse().each { rFile ->
+      def (rMap, rContent) = mdFileComponents(rFile)
+
+      def versionLink = ""
+      def partialVersion = ""
+      def firstPart = true
+      rMap.version.split("\\.").each { part ->
+        def displayPart = ( firstPart ? "" : "." ) + part
+        partialVersion += displayPart
+        if (
+            linkedVersions.contains(partialVersion)
+            || ( actualVersions.contains(partialVersion) && partialVersion != rMap.version )
+            ) {
+          versionLink += displayPart
+        } else {
+          versionLink += "<a id=\"Jalview.${partialVersion}\">${displayPart}</a>"
+          linkedVersions += partialVersion
+        }
+        firstPart = false
+      }
+      def displayDate = releaseFilesDates[rFile].format("dd/MM/yyyy")
+
+      def lm = null
+      def rContentProcessed = ""
+      rContent.eachLine { line ->
+        if (lm = line =~ /^(\s*-)(\s*<!--[^>]*?-->)(.*)$/) {
+          line = "${lm[0][1]}${lm[0][3]}${lm[0][2]}"
+      } else if (lm = line =~ /^###([^#]+.*)$/) {
+          line = "_${lm[0][1].trim()}_"
+        }
+        rContentProcessed += line + "\n"
+      }
+
+      def rContentSections = getMdSections(rContentProcessed)
+      def rVersion = versionTemplate
+      if (rVersion != "") {
+        def rNewFeatures = rContentSections["new_features"]
+        def rIssuesResolved = rContentSections["issues_resolved"]
+        Node newFeaturesNode = parser.parse(rNewFeatures)
+        String newFeaturesHtml = renderer.render(newFeaturesNode)
+        Node issuesResolvedNode = parser.parse(rIssuesResolved)
+        String issuesResolvedHtml = renderer.render(issuesResolvedNode)
+        rVersion = hugoTemplateSubstitutions(rVersion,
+          [
+            VERSION: rMap.version,
+            VERSION_LINK: versionLink,
+            DISPLAY_DATE: displayDate,
+            NEW_FEATURES: newFeaturesHtml,
+            ISSUES_RESOLVED: issuesResolvedHtml
+          ]
+        )
+        versionsHtml += rVersion
+      }
+    }
+
+    releasesTemplate = releasesTemplate.replaceAll("(?s)__VERSION_LOOP_START__.*__VERSION_LOOP_END__", versionsHtml)
+    releasesTemplate = hugoTemplateSubstitutions(releasesTemplate)
+    releasesHtmlFile.text = releasesTemplate
+
+    if (whatsnewMdFile.exists()) {
+      def wnDisplayDate = releaseFilesDates[releaseMdFile] != null ? releaseFilesDates[releaseMdFile].format("dd MMMM yyyy") : ""
+      def whatsnewMd = hugoTemplateSubstitutions(whatsnewMdFile.text)
+      Node whatsnewNode = parser.parse(whatsnewMd)
+      String whatsnewHtml = renderer.render(whatsnewNode)
+      whatsnewHtml = whatsnewTemplateFile.text.replaceAll("__WHATS_NEW__", whatsnewHtml)
+      whatsnewHtmlFile.text = hugoTemplateSubstitutions(whatsnewHtml,
+        [
+            VERSION: JALVIEW_VERSION,
+          DISPLAY_DATE: wnDisplayDate
+        ]
+      )
+    } else if (gradle.taskGraph.hasTask(":linkCheck")) {
+      whatsnewHtmlFile.text = "Development build " + getDate("yyyy-MM-dd HH:mm:ss")
+    }
+
+  }
+
+  inputs.file(releasesTemplateFile)
+  inputs.file(whatsnewTemplateFile)
+  inputs.dir(releasesMdDir)
+  inputs.dir(whatsnewMdDir)
+  outputs.file(releasesHtmlFile)
+  outputs.file(whatsnewHtmlFile)
+}
+
+
 task copyResources(type: Copy) {
   group = "build"
   description = "Copy (and make text substitutions in) the resources dir to the build area"
@@ -1237,7 +1662,19 @@ task copyChannelResources(type: Copy) {
 
   def inputDir = "${channelDir}/${resource_dir}"
   def outputDir = resourcesBuildDir
-  from inputDir
+  from(inputDir) {
+    include(channel_props)
+    filter(ReplaceTokens,
+      beginToken: '__',
+      endToken: '__',
+      tokens: [
+        'SUFFIX': channelSuffix
+      ]
+    )
+  }
+  from(inputDir) {
+    exclude(channel_props)
+  }
   into outputDir
 
   inputs.dir(inputDir)
@@ -1258,6 +1695,7 @@ task createBuildProperties(type: WriteProperties) {
   property "BUILD_DATE", getDate("HH:mm:ss dd MMMM yyyy")
   property "VERSION", JALVIEW_VERSION
   property "INSTALLATION", INSTALLATION+" git-commit:"+gitHash+" ["+gitBranch+"]"
+  property "JAVA_COMPILE_VERSION", JAVA_INTEGER_VERSION
   if (getdownSetAppBaseProperty) {
     property "GETDOWNAPPBASE", getdownAppBase
     property "GETDOWNAPPDISTDIR", getdownAppDistDir
@@ -1295,6 +1733,7 @@ task prepare {
   dependsOn buildResources
   dependsOn copyDocs
   dependsOn copyHelp
+  dependsOn releasesTemplates
   dependsOn convertMdFiles
   dependsOn buildIndices
 }
@@ -1302,26 +1741,140 @@ task prepare {
 
 compileJava.dependsOn prepare
 run.dependsOn compileJava
-//run.dependsOn prepare
+compileTestJava.dependsOn compileJava
+
 
 
-//testReportDirName = "test-reports" // note that test workingDir will be $jalviewDir
 test {
-  dependsOn prepare
+  group = "Verification"
+  description = "Runs all testTaskN tasks)"
 
   if (useClover) {
     dependsOn cloverClasses
-   } else { //?
-    dependsOn compileJava //?
+  } else { //?
+    dependsOn testClasses
+  }
+
+  // not running tests in this task
+  exclude "**/*"
+}
+/* testTask0 is the main test task */
+task testTask0(type: Test) {
+  group = "Verification"
+  description = "The main test task. Runs all non-testTaskN-labelled tests (unless excluded)"
+  useTestNG() {
+    includeGroups testng_groups.split(",")
+    excludeGroups testng_excluded_groups.split(",")
+    tasks.withType(Test).matching {it.name.startsWith("testTask") && it.name != name}.all {t -> excludeGroups t.name}
+    preserveOrder true
+    useDefaultListeners=true
+  }
+}
+
+/* separated tests */
+task testTask1(type: Test) {
+  group = "Verification"
+  description = "Tests that need to be isolated from the main test run"
+  useTestNG() {
+    includeGroups name
+    excludeGroups testng_excluded_groups.split(",")
+    preserveOrder true
+    useDefaultListeners=true
   }
+}
 
+task testTask2(type: Test) {
+  group = "Verification"
+  description = "Tests that need to be isolated from the main test run"
   useTestNG() {
-    includeGroups testng_groups
-    excludeGroups testng_excluded_groups
+    includeGroups name
+    excludeGroups testng_excluded_groups.split(",")
     preserveOrder true
     useDefaultListeners=true
   }
+}
+task testTask3(type: Test) {
+  group = "Verification"
+  description = "Tests that need to be isolated from the main test run"
+  useTestNG() {
+    includeGroups name
+    excludeGroups testng_excluded_groups.split(",")
+    preserveOrder true
+    useDefaultListeners=true
+  }
+}
+
+/* insert more testTaskNs here -- change N to next digit or other string */
+/*
+task testTaskN(type: Test) {
+  group = "Verification"
+  description = "Tests that need to be isolated from the main test run"
+  useTestNG() {
+    includeGroups name
+    excludeGroups testng_excluded_groups.split(",")
+    preserveOrder true
+    useDefaultListeners=true
+  }
+}
+*/
+
+/*
+ * adapted from https://medium.com/@wasyl/pretty-tests-summary-in-gradle-744804dd676c
+ * to summarise test results from all Test tasks
+ */
+/* START of test tasks results summary */
+import groovy.time.TimeCategory
+import org.gradle.api.tasks.testing.logging.TestExceptionFormat
+import org.gradle.api.tasks.testing.logging.TestLogEvent
+rootProject.ext.testsResults = [] // Container for tests summaries
+
+tasks.withType(Test).matching {t -> t.getName().startsWith("testTask")}.all { testTask ->
+
+  // from original test task
+  if (useClover) {
+    dependsOn cloverClasses
+  } else { //?
+    dependsOn testClasses //?
+  }
+
+  // run main tests first
+  if (!testTask.name.equals("testTask0"))
+    testTask.mustRunAfter "testTask0"
+
+  testTask.testLogging { logging ->
+    events TestLogEvent.FAILED
+//      TestLogEvent.SKIPPED,
+//      TestLogEvent.STANDARD_OUT,
+//      TestLogEvent.STANDARD_ERROR
+
+    exceptionFormat TestExceptionFormat.FULL
+    showExceptions true
+    showCauses true
+    showStackTraces true
+    if (test_output) {
+      showStandardStreams true
+    }
+    info.events = [ TestLogEvent.FAILED ]
+  }
+
+  if (OperatingSystem.current().isMacOsX()) {
+    testTask.systemProperty "apple.awt.UIElement", "true"
+    testTask.environment "JAVA_TOOL_OPTIONS", "-Dapple.awt.UIElement=true"
+  }
+
+
+  ignoreFailures = true // Always try to run all tests for all modules
 
+  afterSuite { desc, result ->
+    if (desc.parent)
+      return // Only summarize results for whole modules
+
+    def resultsInfo = [testTask.project.name, testTask.name, result, TimeCategory.minus(new Date(result.endTime), new Date(result.startTime)), testTask.reports.html.entryPoint]
+
+    rootProject.ext.testsResults.add(resultsInfo)
+  }
+
+  // from original test task
   maxHeapSize = "1024m"
 
   workingDir = jalviewDir
@@ -1340,12 +1893,144 @@ test {
   jvmArgs += additional_compiler_args
 
   doFirst {
+    // this is not perfect yet -- we should only add the commandLineIncludePatterns to the
+    // testTasks that include the tests, and exclude all from the others.
+    // get --test argument
+    filter.commandLineIncludePatterns = test.filter.commandLineIncludePatterns
+    // do something with testTask.getCandidateClassFiles() to see if the test should silently finish because of the
+    // commandLineIncludePatterns not matching anything.  Instead we are doing setFailOnNoMatchingTests(false) below
+
+
     if (useClover) {
       println("Running tests " + (useClover?"WITH":"WITHOUT") + " clover")
     }
   }
+
+
+  /* don't fail on no matching tests (so --tests will run across all testTasks) */
+  testTask.filter.setFailOnNoMatchingTests(false)
+
+  /* ensure the "test" task dependsOn all the testTasks */
+  test.dependsOn testTask
+}
+
+gradle.buildFinished {
+    def allResults = rootProject.ext.testsResults
+
+    if (!allResults.isEmpty()) {
+        printResults allResults
+        allResults.each {r ->
+          if (r[2].resultType == TestResult.ResultType.FAILURE)
+            throw new GradleException("Failed tests!")
+        }
+    }
+}
+
+private static String colString(styler, col, colour, text) {
+  return col?"${styler[colour](text)}":text
+}
+
+private static String getSummaryLine(s, pn, tn, rt, rc, rs, rf, rsk, t, col) {
+  def colour = 'black'
+  def text = rt
+  def nocol = false
+  if (rc == 0) {
+    text = "-----"
+    nocol = true
+  } else {
+    switch(rt) {
+      case TestResult.ResultType.SUCCESS:
+        colour = 'green'
+        break;
+      case TestResult.ResultType.FAILURE:
+        colour = 'red'
+        break;
+      default:
+        nocol = true
+        break;
+    }
+  }
+  StringBuilder sb = new StringBuilder()
+  sb.append("${pn}")
+  if (tn != null)
+    sb.append(":${tn}")
+  sb.append(" results: ")
+  sb.append(colString(s, col && !nocol, colour, text))
+  sb.append(" (")
+  sb.append("${rc} tests, ")
+  sb.append(colString(s, col && rs > 0, 'green', rs))
+  sb.append(" successes, ")
+  sb.append(colString(s, col && rf > 0, 'red', rf))
+  sb.append(" failures, ")
+  sb.append("${rsk} skipped) in ${t}")
+  return sb.toString()
 }
 
+private static void printResults(allResults) {
+
+    // styler from https://stackoverflow.com/a/56139852
+    def styler = 'black red green yellow blue magenta cyan white'.split().toList().withIndex(30).collectEntries { key, val -> [(key) : { "\033[${val}m${it}\033[0m" }] }
+
+    def maxLength = 0
+    def failedTests = false
+    def summaryLines = []
+    def totalcount = 0
+    def totalsuccess = 0
+    def totalfail = 0
+    def totalskip = 0
+    def totaltime = TimeCategory.getSeconds(0)
+    // sort on project name then task name
+    allResults.sort {a, b -> a[0] == b[0]? a[1]<=>b[1]:a[0] <=> b[0]}.each {
+      def projectName = it[0]
+      def taskName = it[1]
+      def result = it[2]
+      def time = it[3]
+      def report = it[4]
+      def summaryCol = getSummaryLine(styler, projectName, taskName, result.resultType, result.testCount, result.successfulTestCount, result.failedTestCount, result.skippedTestCount, time, true)
+      def summaryPlain = getSummaryLine(styler, projectName, taskName, result.resultType, result.testCount, result.successfulTestCount, result.failedTestCount, result.skippedTestCount, time, false)
+      def reportLine = "Report file: ${report}"
+      def ls = summaryPlain.length()
+      def lr = reportLine.length()
+      def m = [ls, lr].max()
+      if (m > maxLength)
+        maxLength = m
+      def info = [ls, summaryCol, reportLine]
+      summaryLines.add(info)
+      failedTests |= result.resultType == TestResult.ResultType.FAILURE
+      totalcount += result.testCount
+      totalsuccess += result.successfulTestCount
+      totalfail += result.failedTestCount
+      totalskip += result.skippedTestCount
+      totaltime += time
+    }
+    def totalSummaryCol = getSummaryLine(styler, "OVERALL", "", failedTests?TestResult.ResultType.FAILURE:TestResult.ResultType.SUCCESS, totalcount, totalsuccess, totalfail, totalskip, totaltime, true)
+    def totalSummaryPlain = getSummaryLine(styler, "OVERALL", "", failedTests?TestResult.ResultType.FAILURE:TestResult.ResultType.SUCCESS, totalcount, totalsuccess, totalfail, totalskip, totaltime, false)
+    def tls = totalSummaryPlain.length()
+    if (tls > maxLength)
+      maxLength = tls
+    def info = [tls, totalSummaryCol, null]
+    summaryLines.add(info)
+
+    def allSummaries = []
+    for(sInfo : summaryLines) {
+      def ls = sInfo[0]
+      def summary = sInfo[1]
+      def report = sInfo[2]
+
+      StringBuilder sb = new StringBuilder()
+      sb.append("│" + summary + " " * (maxLength - ls) + "│")
+      if (report != null) {
+        sb.append("\n│" + report + " " * (maxLength - report.length()) + "│")
+      }
+      allSummaries += sb.toString()
+    }
+
+    println "┌${"${"─" * maxLength}"}┐"
+    println allSummaries.join("\n├${"${"─" * maxLength}"}┤\n")
+    println "└${"${"─" * maxLength}"}┘"
+}
+/* END of test tasks results summary */
+
 
 task compileLinkCheck(type: JavaCompile) {
   options.fork = true
@@ -1367,7 +2052,7 @@ task linkCheck(type: JavaExec) {
   def helpLinksCheckerOutFile = file("${jalviewDir}/${utils_dir}/HelpLinksChecker.out")
   classpath = files("${jalviewDir}/${utils_dir}")
   main = "HelpLinksChecker"
-  workingDir = jalviewDir
+  workingDir = "${helpBuildDir}"
   args = [ "${helpBuildDir}/${help_dir}", "-nointernet" ]
 
   def outFOS = new FileOutputStream(helpLinksCheckerOutFile, false) // false == don't append
@@ -1403,7 +2088,7 @@ jar {
   manifest {
     attributes "Main-Class": main_class,
     "Permissions": "all-permissions",
-    "Application-Name": install4jApplicationName,
+    "Application-Name": applicationName,
     "Codebase": application_codebase,
     "Implementation-Version": JALVIEW_VERSION
   }
@@ -1471,12 +2156,33 @@ shadowJar {
   if (buildDist) {
     dependsOn makeDist
   }
-  from ("${jalviewDir}/${libDistDir}") {
-    include("*.jar")
-  }
-  manifest {
-    attributes "Implementation-Version": JALVIEW_VERSION,
-    "Application-Name": install4jApplicationName
+
+  def jarFiles = fileTree(dir: "${jalviewDir}/${libDistDir}", include: "*.jar", exclude: "regex.jar").getFiles()
+  def groovyJars = jarFiles.findAll {it1 -> file(it1).getName().startsWith("groovy-swing")}
+  def otherJars = jarFiles.findAll {it2 -> !file(it2).getName().startsWith("groovy-swing")}
+  from groovyJars
+  from otherJars
+
+  // we need to include the groovy-swing Include-Package for it to run in the shadowJar
+  doFirst {
+    def jarFileManifests = []
+    groovyJars.each { jarFile ->
+      def mf = zipTree(jarFile).getFiles().find { it.getName().equals("MANIFEST.MF") }
+      if (mf != null) {
+        jarFileManifests += mf
+      }
+    }
+
+    manifest {
+      attributes "Implementation-Version": JALVIEW_VERSION, "Application-Name": applicationName
+      from (jarFileManifests) {
+        eachEntry { details ->
+          if (!details.key.equals("Import-Package")) {
+            details.exclude()
+          }
+        }
+      }
+    }
   }
 
   duplicatesStrategy "INCLUDE"
@@ -1487,22 +2193,69 @@ shadowJar {
   minimize()
 }
 
+task getdownImagesCopy() {
+  inputs.dir getdownImagesDir
+  outputs.dir getdownImagesBuildDir
 
-task getdownWebsite() {
+  doFirst {
+    copy {
+      from(getdownImagesDir) {
+        include("*getdown*.png")
+      }
+      into getdownImagesBuildDir
+    }
+  }
+}
+
+task getdownImagesProcess() {
+  dependsOn getdownImagesCopy
+
+  doFirst {
+    if (backgroundImageText) {
+      if (convertBinary == null) {
+        throw new StopExecutionException("No ImageMagick convert binary installed at '${convertBinaryExpectedLocation}'")
+      }
+      if (!project.hasProperty("getdown_background_image_text_suffix_cmd")) {
+        throw new StopExecutionException("No property 'getdown_background_image_text_suffix_cmd' defined. See channel_gradle.properties for channel ${CHANNEL}")
+      }
+      fileTree(dir: getdownImagesBuildDir, include: "*background*.png").getFiles().each { file ->
+        exec {
+          executable convertBinary
+          args = [
+            file.getPath(),
+            '-font', getdown_background_image_text_font,
+            '-fill', getdown_background_image_text_colour,
+            '-draw', sprintf(getdown_background_image_text_suffix_cmd, channelSuffix),
+            '-draw', sprintf(getdown_background_image_text_commit_cmd, "git-commit: ${gitHash}"),
+            '-draw', sprintf(getdown_background_image_text_date_cmd, getDate("yyyy-MM-dd HH:mm:ss")),
+            file.getPath()
+          ]
+        }
+      }
+    }
+  }
+}
+
+task getdownImages() {
+  dependsOn getdownImagesProcess
+}
+
+task getdownWebsiteBuild() {
   group = "distribution"
-  description = "Create the getdown minimal app folder, and website folder for this version of jalview. Website folder also used for offline app installer"
+  description = "Create the getdown minimal app folder, and website folder for this version of jalview. Website folder also used for offline app installer. No digest is created."
+
+  dependsOn getdownImages
   if (buildDist) {
     dependsOn makeDist
   }
 
   def getdownWebsiteResourceFilenames = []
-  def getdownTextString = ""
   def getdownResourceDir = getdownResourceDir
   def getdownResourceFilenames = []
 
   doFirst {
     // clean the getdown website and files dir before creating getdown folders
-    delete getdownWebsiteDir
+    delete getdownAppBaseDir
     delete getdownFilesDir
 
     copy {
@@ -1514,11 +2267,18 @@ task getdownWebsite() {
 
     copy {
       from channelPropsFile
-      into getdownWebsiteDir
+      filter(ReplaceTokens,
+        beginToken: '__',
+        endToken: '__',
+        tokens: [
+          'SUFFIX': channelSuffix
+        ]
+      )
+      into getdownAppBaseDir
     }
     getdownWebsiteResourceFilenames += file(channelPropsFile).getName()
 
-    // set some getdown_txt_ properties then go through all properties looking for getdown_txt_...
+    // set some getdownTxt_ properties then go through all properties looking for getdownTxt_...
     def props = project.properties.sort { it.key }
     if (getdownAltJavaMinVersion != null && getdownAltJavaMinVersion.length() > 0) {
       props.put("getdown_txt_java_min_version", getdownAltJavaMinVersion)
@@ -1529,27 +2289,27 @@ task getdownWebsite() {
     if (getdownAltMultiJavaLocation != null && getdownAltMultiJavaLocation.length() > 0) {
       props.put("getdown_txt_multi_java_location", getdownAltMultiJavaLocation)
     }
-    if (getdownImagesDir != null && file(getdownImagesDir).exists()) {
-      props.put("getdown_txt_ui.background_image", "${getdownImagesDir}/${getdown_background_image}")
-      props.put("getdown_txt_ui.instant_background_image", "${getdownImagesDir}/${getdown_instant_background_image}")
-      props.put("getdown_txt_ui.error_background", "${getdownImagesDir}/${getdown_error_background}")
-      props.put("getdown_txt_ui.progress_image", "${getdownImagesDir}/${getdown_progress_image}")
+    if (getdownImagesBuildDir != null && file(getdownImagesBuildDir).exists()) {
+      props.put("getdown_txt_ui.background_image", "${getdownImagesBuildDir}/${getdown_background_image}")
+      props.put("getdown_txt_ui.instant_background_image", "${getdownImagesBuildDir}/${getdown_instant_background_image}")
+      props.put("getdown_txt_ui.error_background", "${getdownImagesBuildDir}/${getdown_error_background}")
+      props.put("getdown_txt_ui.progress_image", "${getdownImagesBuildDir}/${getdown_progress_image}")
       props.put("getdown_txt_ui.icon", "${getdownImagesDir}/${getdown_icon}")
       props.put("getdown_txt_ui.mac_dock_icon", "${getdownImagesDir}/${getdown_mac_dock_icon}")
     }
 
     props.put("getdown_txt_title", jalview_name)
-    props.put("getdown_txt_ui.name", install4jApplicationName)
+    props.put("getdown_txt_ui.name", applicationName)
 
     // start with appbase
-    getdownTextString += "appbase = ${getdownAppBase}\n"
+    getdownTextLines += "appbase = ${getdownAppBase}"
     props.each{ prop, val ->
       if (prop.startsWith("getdown_txt_") && val != null) {
         if (prop.startsWith("getdown_txt_multi_")) {
           def key = prop.substring(18)
           val.split(",").each{ v ->
-            def line = "${key} = ${v}\n"
-            getdownTextString += line
+            def line = "${key} = ${v}"
+            getdownTextLines += line
           }
         } else {
           // file values rationalised
@@ -1569,15 +2329,15 @@ task getdownWebsite() {
             }
           }
           if (! prop.startsWith("getdown_txt_resource")) {
-            def line = prop.substring(12) + " = ${val}\n"
-            getdownTextString += line
+            def line = prop.substring(12) + " = ${val}"
+            getdownTextLines += line
           }
         }
       }
     }
 
     getdownWebsiteResourceFilenames.each{ filename ->
-      getdownTextString += "resource = ${filename}\n"
+      getdownTextLines += "resource = ${filename}"
     }
     getdownResourceFilenames.each{ filename ->
       copy {
@@ -1592,9 +2352,9 @@ task getdownWebsite() {
       if (s.exists()) {
         copy {
           from s
-          into "${getdownWebsiteDir}/${getdown_wrapper_script_dir}"
+          into "${getdownAppBaseDir}/${getdown_wrapper_script_dir}"
         }
-        getdownTextString += "resource = ${getdown_wrapper_script_dir}/${script}\n"
+        getdownTextLines += "xresource = ${getdown_wrapper_script_dir}/${script}"
       }
     }
 
@@ -1607,10 +2367,12 @@ task getdownWebsite() {
         codeFiles += f
       }
     }
-    codeFiles.sort().each{f ->
+    def jalviewJar = jar.archiveFileName.getOrNull()
+    // put jalview.jar first for CLASSPATH and .properties files reasons
+    codeFiles.sort{a, b -> ( a.getName() == jalviewJar ? -1 : ( b.getName() == jalviewJar ? 1 : a <=> b ) ) }.each{f ->
       def name = f.getName()
-      def line = "code = ${getdownAppDistDir}/${name}\n"
-      getdownTextString += line
+      def line = "code = ${getdownAppDistDir}/${name}"
+      getdownTextLines += line
       copy {
         from f.getPath()
         into getdownAppDir
@@ -1623,8 +2385,8 @@ task getdownWebsite() {
     def j11libFiles = fileTree(dir: "${jalviewDir}/${j11libDir}", include: ["*.jar"]).getFiles()
     j11libFiles.sort().each{f ->
     def name = f.getName()
-    def line = "code = ${getdown_j11lib_dir}/${name}\n"
-    getdownTextString += line
+    def line = "code = ${getdown_j11lib_dir}/${name}"
+    getdownTextLines += line
     copy {
     from f.getPath()
     into getdownJ11libDir
@@ -1634,27 +2396,27 @@ task getdownWebsite() {
      */
 
     // getdown-launcher.jar should not be in main application class path so the main application can move it when updated.  Listed as a resource so it gets updated.
-    //getdownTextString += "class = " + file(getdownLauncher).getName() + "\n"
-    getdownTextString += "resource = ${getdown_launcher_new}\n"
-    getdownTextString += "class = ${main_class}\n"
+    //getdownTextLines += "class = " + file(getdownLauncher).getName()
+    getdownTextLines += "resource = ${getdown_launcher_new}"
+    getdownTextLines += "class = ${main_class}"
     // Not setting these properties in general so that getdownappbase and getdowndistdir will default to release version in jalview.bin.Cache
     if (getdownSetAppBaseProperty) {
-      getdownTextString += "jvmarg = -Dgetdowndistdir=${getdownAppDistDir}\n"
-      getdownTextString += "jvmarg = -Dgetdownappbase=${getdownAppBase}\n"
+      getdownTextLines += "jvmarg = -Dgetdowndistdir=${getdownAppDistDir}"
+      getdownTextLines += "jvmarg = -Dgetdownappbase=${getdownAppBase}"
     }
 
-    def getdown_txt = file("${getdownWebsiteDir}/getdown.txt")
-    getdown_txt.write(getdownTextString)
+    def getdownTxt = file("${getdownAppBaseDir}/getdown.txt")
+    getdownTxt.write(getdownTextLines.join("\n"))
 
-    def getdownLaunchJvl = getdown_launch_jvl_name + ( (jvlChannelName != null && jvlChannelName.length() > 0)?"-${jvlChannelName}":"" ) + ".jvl"
-    def launchJvl = file("${getdownWebsiteDir}/${getdownLaunchJvl}")
+    getdownLaunchJvl = getdown_launch_jvl_name + ( (jvlChannelName != null && jvlChannelName.length() > 0)?"-${jvlChannelName}":"" ) + ".jvl"
+    def launchJvl = file("${getdownAppBaseDir}/${getdownLaunchJvl}")
     launchJvl.write("appbase=${getdownAppBase}")
 
     // files going into the getdown website dir: getdown-launcher.jar
     copy {
       from getdownLauncher
       rename(file(getdownLauncher).getName(), getdown_launcher_new)
-      into getdownWebsiteDir
+      into getdownAppBaseDir
     }
 
     // files going into the getdown website dir: getdown-launcher(-local).jar
@@ -1663,13 +2425,13 @@ task getdownWebsite() {
       if (file(getdownLauncher).getName() != getdown_launcher) {
         rename(file(getdownLauncher).getName(), getdown_launcher)
       }
-      into getdownWebsiteDir
+      into getdownAppBaseDir
     }
 
     // files going into the getdown website dir: ./install dir and files
     if (! (CHANNEL.startsWith("ARCHIVE") || CHANNEL.startsWith("DEVELOP"))) {
       copy {
-        from getdown_txt
+        from getdownTxt
         from getdownLauncher
         from "${getdownAppDir}/${getdown_build_properties}"
         if (file(getdownLauncher).getName() != getdown_launcher) {
@@ -1687,18 +2449,18 @@ task getdownWebsite() {
 
     // files going into the getdown files dir: getdown.txt, getdown-launcher.jar, channel-launch.jvl, build_properties
     copy {
-      from getdown_txt
+      from getdownTxt
       from launchJvl
       from getdownLauncher
-      from "${getdownWebsiteDir}/${getdown_build_properties}"
-      from "${getdownWebsiteDir}/${channel_props}"
+      from "${getdownAppBaseDir}/${getdown_build_properties}"
+      from "${getdownAppBaseDir}/${channel_props}"
       if (file(getdownLauncher).getName() != getdown_launcher) {
         rename(file(getdownLauncher).getName(), getdown_launcher)
       }
       into getdownFilesDir
     }
 
-    // and ./resources (not all downloaded by getdown)
+    // and ./resource (not all downloaded by getdown)
     copy {
       from getdownResourceDir
       into "${getdownFilesDir}/${getdown_resource_dir}"
@@ -1708,7 +2470,7 @@ task getdownWebsite() {
   if (buildDist) {
     inputs.dir("${jalviewDir}/${package_dir}")
   }
-  outputs.dir(getdownWebsiteDir)
+  outputs.dir(getdownAppBaseDir)
   outputs.dir(getdownFilesDir)
 }
 
@@ -1734,14 +2496,16 @@ task getdownDigestDir(type: JavaExec) {
 task getdownDigest(type: JavaExec) {
   group = "distribution"
   description = "Digest the getdown website folder"
-  dependsOn getdownWebsite
+
+  dependsOn getdownWebsiteBuild
+
   doFirst {
     classpath = files(getdownLauncher)
   }
   main = "com.threerings.getdown.tools.Digester"
-  args getdownWebsiteDir
-  inputs.dir(getdownWebsiteDir)
-  outputs.file("${getdownWebsiteDir}/digest2.txt")
+  args getdownAppBaseDir
+  inputs.dir(getdownAppBaseDir)
+  outputs.file("${getdownAppBaseDir}/digest2.txt")
 }
 
 
@@ -1751,7 +2515,7 @@ task getdown() {
   dependsOn getdownDigest
   doLast {
     if (reportRsyncCommand) {
-      def fromDir = getdownWebsiteDir + (getdownWebsiteDir.endsWith('/')?'':'/')
+      def fromDir = getdownAppBaseDir + (getdownAppBaseDir.endsWith('/')?'':'/')
       def toDir = "${getdown_rsync_dest}/${getdownDir}" + (getdownDir.endsWith('/')?'':'/')
       println "LIKELY RSYNC COMMAND:"
       println "mkdir -p '$toDir'\nrsync -avh --delete '$fromDir' '$toDir'"
@@ -1767,6 +2531,113 @@ task getdown() {
   }
 }
 
+task getdownWebsite {
+  group = "distribution"
+  description = "A task to create the whole getdown channel website dir including digest file"
+
+  dependsOn getdownWebsiteBuild
+  dependsOn getdownDigest
+}
+
+task getdownArchiveBuild() {
+  group = "distribution"
+  description = "Put files in the archive dir to go on the website"
+
+  dependsOn getdownWebsiteBuild
+
+  def v = "v${JALVIEW_VERSION_UNDERSCORES}"
+  def vDir = "${getdownArchiveDir}/${v}"
+  getdownFullArchiveDir = "${vDir}/getdown"
+  getdownVersionLaunchJvl = "${vDir}/jalview-${v}.jvl"
+
+  def vAltDir = "alt_${v}"
+  def archiveImagesDir = "${jalviewDir}/${channel_properties_dir}/old/images"
+
+  doFirst {
+    // cleanup old "old" dir
+    delete getdownArchiveDir
+
+    def getdownArchiveTxt = file("${getdownFullArchiveDir}/getdown.txt")
+    getdownArchiveTxt.getParentFile().mkdirs()
+    def getdownArchiveTextLines = []
+    def getdownFullArchiveAppBase = "${getdownArchiveAppBase}${getdownArchiveAppBase.endsWith("/")?"":"/"}${v}/getdown/"
+
+    // the libdir
+    copy {
+      from "${getdownAppBaseDir}/${getdownAppDistDir}"
+      into "${getdownFullArchiveDir}/${vAltDir}"
+    }
+
+    getdownTextLines.each { line ->
+      line = line.replaceAll("^(?<s>appbase\\s*=\\s*).*", '${s}'+getdownFullArchiveAppBase)
+      line = line.replaceAll("^(?<s>(resource|code)\\s*=\\s*)${getdownAppDistDir}/", '${s}'+vAltDir+"/")
+      line = line.replaceAll("^(?<s>ui.background_image\\s*=\\s*).*\\.png", '${s}'+"${getdown_resource_dir}/jalview_archive_getdown_background.png")
+      line = line.replaceAll("^(?<s>ui.instant_background_image\\s*=\\s*).*\\.png", '${s}'+"${getdown_resource_dir}/jalview_archive_getdown_background_initialising.png")
+      line = line.replaceAll("^(?<s>ui.error_background\\s*=\\s*).*\\.png", '${s}'+"${getdown_resource_dir}/jalview_archive_getdown_background_error.png")
+      line = line.replaceAll("^(?<s>ui.progress_image\\s*=\\s*).*\\.png", '${s}'+"${getdown_resource_dir}/jalview_archive_getdown_progress_bar.png")
+      // remove the existing resource = resource/ or bin/ lines
+      if (! line.matches("resource\\s*=\\s*(resource|bin)/.*")) {
+        getdownArchiveTextLines += line
+      }
+    }
+
+    // the resource dir -- add these files as resource lines in getdown.txt
+    copy {
+      from "${archiveImagesDir}"
+      into "${getdownFullArchiveDir}/${getdown_resource_dir}"
+      eachFile { file ->
+        getdownArchiveTextLines += "resource = ${getdown_resource_dir}/${file.getName()}"
+      }
+    }
+
+    getdownArchiveTxt.write(getdownArchiveTextLines.join("\n"))
+
+    def vLaunchJvl = file(getdownVersionLaunchJvl)
+    vLaunchJvl.getParentFile().mkdirs()
+    vLaunchJvl.write("appbase=${getdownFullArchiveAppBase}\n")
+    def vLaunchJvlPath = vLaunchJvl.toPath().toAbsolutePath()
+    def jvlLinkPath = file("${vDir}/jalview.jvl").toPath().toAbsolutePath()
+    // for some reason filepath.relativize(fileInSameDirPath) gives a path to "../" which is wrong
+    //java.nio.file.Files.createSymbolicLink(jvlLinkPath, jvlLinkPath.relativize(vLaunchJvlPath));
+    java.nio.file.Files.createSymbolicLink(jvlLinkPath, java.nio.file.Paths.get(".",vLaunchJvl.getName()));
+
+    // files going into the getdown files dir: getdown.txt, getdown-launcher.jar, channel-launch.jvl, build_properties
+    copy {
+      from getdownLauncher
+      from "${getdownAppBaseDir}/${getdownLaunchJvl}"
+      from "${getdownAppBaseDir}/${getdown_launcher_new}"
+      from "${getdownAppBaseDir}/${channel_props}"
+      if (file(getdownLauncher).getName() != getdown_launcher) {
+        rename(file(getdownLauncher).getName(), getdown_launcher)
+      }
+      into getdownFullArchiveDir
+    }
+
+  }
+}
+
+task getdownArchiveDigest(type: JavaExec) {
+  group = "distribution"
+  description = "Digest the getdown archive folder"
+
+  dependsOn getdownArchiveBuild
+
+  doFirst {
+    classpath = files(getdownLauncher)
+    args getdownFullArchiveDir
+  }
+  main = "com.threerings.getdown.tools.Digester"
+  inputs.dir(getdownFullArchiveDir)
+  outputs.file("${getdownFullArchiveDir}/digest2.txt")
+}
+
+task getdownArchive() {
+  group = "distribution"
+  description = "Build the website archive dir with getdown digest"
+
+  dependsOn getdownArchiveBuild
+  dependsOn getdownArchiveDigest
+}
 
 tasks.withType(JavaCompile) {
        options.encoding = 'UTF-8'
@@ -1775,8 +2646,9 @@ tasks.withType(JavaCompile) {
 
 clean {
   doFirst {
-    delete getdownWebsiteDir
+    delete getdownAppBaseDir
     delete getdownFilesDir
+    delete getdownArchiveDir
   }
 }
 
@@ -1816,13 +2688,15 @@ task copyInstall4jTemplate {
       }
     }
 
+    // disable install screen for OSX dmg (for 2.11.2.0)
+    install4jConfigXml.'**'.macosArchive.each { macosArchive -> 
+      macosArchive.attributes().remove('executeSetupApp')
+      macosArchive.attributes().remove('setupAppId')
+    }
+
     // turn off checksum creation for LOCAL channel
     def e = install4jConfigXml.application[0]
-    if (CHANNEL == "LOCAL") {
-      e.'@createChecksums' = "false"
-    } else {
-      e.'@createChecksums' = "true"
-    }
+    e.'@createChecksums' = string(install4jCheckSums)
 
     // put file association actions where placeholder action is
     def install4jFileAssociationsText = install4jFileAssociationsFile.text
@@ -1875,12 +2749,70 @@ clean {
   }
 }
 
+task cleanInstallersDataFiles {
+  def installersOutputTxt = file("${jalviewDir}/${install4jBuildDir}/output.txt")
+  def installersSha256 = file("${jalviewDir}/${install4jBuildDir}/sha256sums")
+  def hugoDataJsonFile = file("${jalviewDir}/${install4jBuildDir}/installers-${JALVIEW_VERSION_UNDERSCORES}.json")
+  doFirst {
+    delete installersOutputTxt
+    delete installersSha256
+    delete hugoDataJsonFile
+  }
+}
+
+task install4jDMGBackgroundImageCopy {
+  inputs.file "${install4jDMGBackgroundImageDir}/${install4jDMGBackgroundImageFile}"
+  outputs.dir "${install4jDMGBackgroundImageBuildDir}"
+  doFirst {
+    copy {
+      from(install4jDMGBackgroundImageDir) {
+        include(install4jDMGBackgroundImageFile)
+      }
+      into install4jDMGBackgroundImageBuildDir
+    }
+  }
+}
 
-task installers(type: com.install4j.gradle.Install4jTask) {
+task install4jDMGBackgroundImageProcess {
+  dependsOn install4jDMGBackgroundImageCopy
+
+  doFirst {
+    if (backgroundImageText) {
+      if (convertBinary == null) {
+        throw new StopExecutionException("No ImageMagick convert binary installed at '${convertBinaryExpectedLocation}'")
+      }
+      if (!project.hasProperty("install4j_background_image_text_suffix_cmd")) {
+        throw new StopExecutionException("No property 'install4j_background_image_text_suffix_cmd' defined. See channel_gradle.properties for channel ${CHANNEL}")
+      }
+      fileTree(dir: install4jDMGBackgroundImageBuildDir, include: "*.png").getFiles().each { file ->
+        exec {
+          executable convertBinary
+          args = [
+            file.getPath(),
+            '-font', install4j_background_image_text_font,
+            '-fill', install4j_background_image_text_colour,
+            '-draw', sprintf(install4j_background_image_text_suffix_cmd, channelSuffix),
+            '-draw', sprintf(install4j_background_image_text_commit_cmd, "git-commit: ${gitHash}"),
+            '-draw', sprintf(install4j_background_image_text_date_cmd, getDate("yyyy-MM-dd HH:mm:ss")),
+            file.getPath()
+          ]
+        }
+      }
+    }
+  }
+}
+
+task install4jDMGBackgroundImage {
+  dependsOn install4jDMGBackgroundImageProcess
+}
+
+task installerFiles(type: com.install4j.gradle.Install4jTask) {
   group = "distribution"
   description = "Create the install4j installers"
   dependsOn getdown
   dependsOn copyInstall4jTemplate
+  dependsOn cleanInstallersDataFiles
+  dependsOn install4jDMGBackgroundImage
 
   projectFile = install4jConfFile
 
@@ -1895,12 +2827,10 @@ task installers(type: com.install4j.gradle.Install4jTask) {
     filesMd5 = filesMd5.substring(0,8)
   }
   def install4jTemplateVersion = "${JALVIEW_VERSION}_F${filesMd5}_C${gitHash}"
-  // make install4jBuildDir relative to jalviewDir
-  def install4jBuildDir = "${install4j_build_dir}/${JAVA_VERSION}"
 
   variables = [
     'JALVIEW_NAME': jalview_name,
-    'JALVIEW_APPLICATION_NAME': install4jApplicationName,
+    'JALVIEW_APPLICATION_NAME': applicationName,
     'JALVIEW_DIR': "../..",
     'OSX_KEYSTORE': OSX_KEYSTORE,
     'OSX_APPLEID': OSX_APPLEID,
@@ -1914,25 +2844,20 @@ task installers(type: com.install4j.gradle.Install4jTask) {
     'JAVA_VERSION': JAVA_VERSION,
     'JAVA_INTEGER_VERSION': JAVA_INTEGER_VERSION,
     'VERSION': JALVIEW_VERSION,
-    'MACOS_JAVA_VM_DIR': macosJavaVMDir,
-    'WINDOWS_JAVA_VM_DIR': windowsJavaVMDir,
-    'LINUX_JAVA_VM_DIR': linuxJavaVMDir,
-    'MACOS_JAVA_VM_TGZ': macosJavaVMTgz,
-    'WINDOWS_JAVA_VM_TGZ': windowsJavaVMTgz,
-    'LINUX_JAVA_VM_TGZ': linuxJavaVMTgz,
     'COPYRIGHT_MESSAGE': install4j_copyright_message,
     'BUNDLE_ID': install4jBundleId,
     'INTERNAL_ID': install4jInternalId,
     'WINDOWS_APPLICATION_ID': install4jWinApplicationId,
     'MACOS_DMG_DS_STORE': install4jDMGDSStore,
-    'MACOS_DMG_BG_IMAGE': install4jDMGBackgroundImage,
+    'MACOS_DMG_BG_IMAGE': "${install4jDMGBackgroundImageBuildDir}/${install4jDMGBackgroundImageFile}",
     'WRAPPER_LINK': getdownWrapperLink,
     'BASH_WRAPPER_SCRIPT': getdown_bash_wrapper_script,
     'POWERSHELL_WRAPPER_SCRIPT': getdown_powershell_wrapper_script,
+    'BATCH_WRAPPER_SCRIPT': getdown_batch_wrapper_script,
     'WRAPPER_SCRIPT_BIN_DIR': getdown_wrapper_script_dir,
     'INSTALLER_NAME': install4jInstallerName,
     'INSTALL4J_UTILS_DIR': install4j_utils_dir,
-    'GETDOWN_WEBSITE_DIR': getdown_website_dir,
+    'GETDOWN_CHANNEL_DIR': getdownChannelDir,
     'GETDOWN_FILES_DIR': getdown_files_dir,
     'GETDOWN_RESOURCE_DIR': getdown_resource_dir,
     'GETDOWN_DIST_DIR': getdownAppDistDir,
@@ -1949,8 +2874,29 @@ task installers(type: com.install4j.gradle.Install4jTask) {
     'WINDOWS_ICONS_FILE': install4jWindowsIconsFile,
     'PNG_ICON_FILE': install4jPngIconFile,
     'BACKGROUND': install4jBackground,
+  ]
 
+  def varNameMap = [
+    'mac': 'MACOS',
+    'windows': 'WINDOWS',
+    'linux': 'LINUX'
+  ]
+  
+  // these are the bundled OS/architecture VMs needed by install4j
+  def osArch = [
+    [ "mac", "x64" ],
+    [ "mac", "aarch64" ],
+    [ "windows", "x64" ],
+    [ "linux", "x64" ],
+    [ "linux", "aarch64" ]
   ]
+  osArch.forEach { os, arch ->
+    variables[ sprintf("%s_%s_JAVA_VM_DIR", varNameMap[os], arch.toUpperCase(Locale.ROOT)) ] = sprintf("%s/jre-%s-%s-%s/jre", jreInstallsDir, JAVA_INTEGER_VERSION, os, arch)
+    // N.B. For some reason install4j requires the below filename to have underscores and not hyphens
+    // otherwise running `gradle installers` generates a non-useful error:
+    // `install4j: compilation failed. Reason: java.lang.NumberFormatException: For input string: "windows"`
+    variables[ sprintf("%s_%s_JAVA_VM_TGZ", varNameMap[os], arch.toUpperCase(Locale.ROOT)) ] = sprintf("%s/tgz/jre_%s_%s_%s.tar.gz", jreInstallsDir, JAVA_INTEGER_VERSION, os, arch)
+  }
 
   //println("INSTALL4J VARIABLES:")
   //variables.each{k,v->println("${k}=${v}")}
@@ -1981,14 +2927,90 @@ task installers(type: com.install4j.gradle.Install4jTask) {
   }
   //verbose=true
 
-  inputs.dir(getdownWebsiteDir)
+  inputs.dir(getdownAppBaseDir)
   inputs.file(install4jConfFile)
   inputs.file("${install4jDir}/${install4j_info_plist_file_associations}")
-  inputs.dir(macosJavaVMDir)
-  inputs.dir(windowsJavaVMDir)
   outputs.dir("${jalviewDir}/${install4j_build_dir}/${JAVA_VERSION}")
 }
 
+def getDataHash(File myFile) {
+  HashCode hash = Files.asByteSource(myFile).hash(Hashing.sha256())
+  return myFile.exists()
+  ? [
+      "file" : myFile.getName(),
+      "filesize" : myFile.length(),
+      "sha256" : hash.toString()
+    ]
+  : null
+}
+
+def writeDataJsonFile(File installersOutputTxt, File installersSha256, File dataJsonFile) {
+  def hash = [
+    "channel" : getdownChannelName,
+    "date" : getDate("yyyy-MM-dd HH:mm:ss"),
+    "git-commit" : "${gitHash} [${gitBranch}]",
+    "version" : JALVIEW_VERSION
+  ]
+  // install4j installer files
+  if (installersOutputTxt.exists()) {
+    def idHash = [:]
+    installersOutputTxt.readLines().each { def line ->
+      if (line.startsWith("#")) {
+        return;
+      }
+      line.replaceAll("\n","")
+      def vals = line.split("\t")
+      def filename = vals[3]
+      def filesize = file(filename).length()
+      filename = filename.replaceAll(/^.*\//, "")
+      hash[vals[0]] = [ "id" : vals[0], "os" : vals[1], "name" : vals[2], "file" : filename, "filesize" : filesize ]
+      idHash."${filename}" = vals[0]
+    }
+    if (install4jCheckSums && installersSha256.exists()) {
+      installersSha256.readLines().each { def line ->
+        if (line.startsWith("#")) {
+          return;
+        }
+        line.replaceAll("\n","")
+        def vals = line.split(/\s+\*?/)
+        def filename = vals[1]
+        def innerHash = (hash.(idHash."${filename}"))."sha256" = vals[0]
+      }
+    }
+  }
+
+  [
+    "JAR": shadowJar.archiveFile, // executable JAR
+    "JVL": getdownVersionLaunchJvl, // version JVL
+    "SOURCE": sourceDist.archiveFile // source TGZ
+  ].each { key, value ->
+    def file = file(value)
+    if (file.exists()) {
+      def fileHash = getDataHash(file)
+      if (fileHash != null) {
+        hash."${key}" = fileHash;
+      }
+    }
+  }
+  return dataJsonFile.write(new JsonBuilder(hash).toPrettyString())
+}
+
+task staticMakeInstallersJsonFile {
+  doFirst {
+    def output = findProperty("i4j_output")
+    def sha256 = findProperty("i4j_sha256")
+    def json = findProperty("i4j_json")
+    if (output == null || sha256 == null || json == null) {
+      throw new GradleException("Must provide paths to all of output.txt, sha256sums, and output.json with '-Pi4j_output=... -Pi4j_sha256=... -Pi4j_json=...")
+    }
+    writeDataJsonFile(file(output), file(sha256), file(json))
+  }
+}
+
+task installers {
+  dependsOn installerFiles
+}
+
 
 spotless {
   java {
@@ -1996,6 +3018,22 @@ spotless {
   }
 }
 
+task createSourceReleaseProperties(type: WriteProperties) {
+  group = "distribution"
+  description = "Create the source RELEASE properties file"
+  
+  def sourceTarBuildDir = "${buildDir}/sourceTar"
+  def sourceReleasePropertiesFile = "${sourceTarBuildDir}/RELEASE"
+  outputFile (sourceReleasePropertiesFile)
+
+  doFirst {
+    releaseProps.each{ key, val -> property key, val }
+    property "git.branch", gitBranch
+    property "git.hash", gitHash
+  }
+
+  outputs.file(outputFile)
+}
 
 task sourceDist(type: Tar) {
   group "distribution"
@@ -2003,9 +3041,11 @@ task sourceDist(type: Tar) {
 
   dependsOn createBuildProperties
   dependsOn convertMdFiles
+  dependsOn eclipseAllPreferences
+  dependsOn createSourceReleaseProperties
+
 
-  def VERSION_UNDERSCORES = JALVIEW_VERSION.replaceAll("\\.", "_")
-  def outputFileName = "${project.name}_${VERSION_UNDERSCORES}.tar.gz"
+  def outputFileName = "${project.name}_${JALVIEW_VERSION_UNDERSCORES}.tar.gz"
   archiveFileName = outputFileName
   
   compression Compression.GZIP
@@ -2013,6 +3053,7 @@ task sourceDist(type: Tar) {
   into project.name
 
   def EXCLUDE_FILES=[
+    "dist/*",
     "build/*",
     "bin/*",
     "test-output/",
@@ -2027,6 +3068,7 @@ task sourceDist(type: Tar) {
     "*locales/**",
     "utils/InstallAnywhere",
     "**/*.log",
+    "RELEASE",
   ] 
   def PROCESS_FILES=[
     "AUTHORS",
@@ -2036,7 +3078,6 @@ task sourceDist(type: Tar) {
     "FEATURETODO",
     "LICENSE",
     "**/README",
-    "RELEASE",
     "THIRDPARTYLIBS",
     "TESTNG",
     "build.gradle",
@@ -2051,7 +3092,9 @@ task sourceDist(type: Tar) {
     "**/*.sh",
   ]
   def INCLUDE_FILES=[
-    ".settings/org.eclipse.jdt.core.jalview.prefs",
+    ".classpath",
+    ".settings/org.eclipse.buildship.core.prefs",
+    ".settings/org.eclipse.jdt.core.prefs"
   ]
 
   from(jalviewDir) {
@@ -2075,7 +3118,9 @@ task sourceDist(type: Tar) {
     exclude ("utils/InstallAnywhere")
 
     exclude (getdown_files_dir)
-    exclude (getdown_website_dir)
+    // getdown_website_dir and getdown_archive_dir moved to build/website/docroot/getdown
+    //exclude (getdown_website_dir)
+    //exclude (getdown_archive_dir)
 
     // exluding these as not using jars as modules yet
     exclude ("${j11modDir}/**/*.jar")
@@ -2098,10 +3143,51 @@ task sourceDist(type: Tar) {
     })
   }
 
+  def sourceTarBuildDir = "${buildDir}/sourceTar"
+  from(sourceTarBuildDir) {
+    // this includes the appended RELEASE properties file
+  }
 }
 
+task dataInstallersJson {
+  group "website"
+  description "Create the installers-VERSION.json data file for installer files created"
+
+  mustRunAfter installers
+  mustRunAfter shadowJar
+  mustRunAfter sourceDist
+  mustRunAfter getdownArchive
+
+  def installersOutputTxt = file("${jalviewDir}/${install4jBuildDir}/output.txt")
+  def installersSha256 = file("${jalviewDir}/${install4jBuildDir}/sha256sums")
+
+  if (installersOutputTxt.exists()) {
+    inputs.file(installersOutputTxt)
+  }
+  if (install4jCheckSums && installersSha256.exists()) {
+    inputs.file(installersSha256)
+  }
+  [
+    shadowJar.archiveFile, // executable JAR
+    getdownVersionLaunchJvl, // version JVL
+    sourceDist.archiveFile // source TGZ
+  ].each { fileName ->
+    if (file(fileName).exists()) {
+      inputs.file(fileName)
+    }
+  }
+
+  outputs.file(hugoDataJsonFile)
+
+  doFirst {
+    writeDataJsonFile(installersOutputTxt, installersSha256, hugoDataJsonFile)
+  }
+}
 
 task helppages {
+  group "help"
+  description "Copies all help pages to build dir. Runs ant task 'pubhtmlhelp'."
+
   dependsOn copyHelp
   dependsOn pubhtmlhelp
   
@@ -3182,8 +4268,157 @@ task eclipseAutoBuildTask {
 }
 
 
+task jalviewjsCopyStderrLaunchFile(type: Copy) {
+  from file(jalviewjs_stderr_launch)
+  into jalviewjsSiteDir
+
+  inputs.file jalviewjs_stderr_launch
+  outputs.file jalviewjsStderrLaunchFilename
+}
+
+task cleanJalviewjsChromiumUserDir {
+  doFirst {
+    delete jalviewjsChromiumUserDir
+  }
+  outputs.dir jalviewjsChromiumUserDir
+  // always run when depended on
+  outputs.upToDateWhen { !file(jalviewjsChromiumUserDir).exists() }
+}
+
+task jalviewjsChromiumProfile {
+  dependsOn cleanJalviewjsChromiumUserDir
+  mustRunAfter cleanJalviewjsChromiumUserDir
+
+  def firstRun = file("${jalviewjsChromiumUserDir}/First Run")
+
+  doFirst {
+    mkdir jalviewjsChromiumProfileDir
+    firstRun.text = ""
+  }
+  outputs.file firstRun
+}
+
+task jalviewjsLaunchTest {
+  group "Test"
+  description "Check JalviewJS opens in a browser"
+  dependsOn jalviewjsBuildSite
+  dependsOn jalviewjsCopyStderrLaunchFile
+  dependsOn jalviewjsChromiumProfile
+
+  def macOS = OperatingSystem.current().isMacOsX()
+  def chromiumBinary = macOS ? jalviewjs_macos_chromium_binary : jalviewjs_chromium_binary
+  if (chromiumBinary.startsWith("~/")) {
+    chromiumBinary = System.getProperty("user.home") + chromiumBinary.substring(1)
+  }
+  
+  def stdout
+  def stderr
+  doFirst {
+    def timeoutms = Integer.valueOf(jalviewjs_chromium_overall_timeout) * 1000
+    
+    def binary = file(chromiumBinary)
+    if (!binary.exists()) {
+      throw new StopExecutionException("Could not find chromium binary '${chromiumBinary}'. Cannot run task ${name}.")
+    }
+    stdout = new ByteArrayOutputStream()
+    stderr = new ByteArrayOutputStream()
+    def execStdout
+    def execStderr
+    if (jalviewjs_j2s_to_console.equals("true")) {
+      execStdout = new org.apache.tools.ant.util.TeeOutputStream(
+        stdout,
+        System.out)
+      execStderr = new org.apache.tools.ant.util.TeeOutputStream(
+        stderr,
+        System.err)
+    } else {
+      execStdout = stdout
+      execStderr = stderr
+    }
+    def execArgs = [
+      "--no-sandbox", // --no-sandbox IS USED BY THE THORIUM APPIMAGE ON THE BUILDSERVER
+      "--headless=new",
+      "--disable-gpu",
+      "--timeout=${timeoutms}",
+      "--virtual-time-budget=${timeoutms}",
+      "--user-data-dir=${jalviewDirAbsolutePath}/${jalviewjsBuildDir}/${jalviewjs_chromium_user_dir}",
+      "--profile-directory=${jalviewjs_chromium_profile_name}",
+      "--allow-file-access-from-files",
+      "--enable-logging=stderr",
+      "file://${jalviewDirAbsolutePath}/${jalviewjsStderrLaunchFilename}"
+    ]
+    
+    if (true || macOS) {
+      ScheduledExecutorService executor = Executors.newScheduledThreadPool(3);
+      Future f1 = executor.submit(
+        () -> {
+          exec {
+            standardOutput = execStdout
+            errorOutput = execStderr
+            executable(chromiumBinary)
+            args(execArgs)
+            println "COMMAND: '"+commandLine.join(" ")+"'"
+          }
+          executor.shutdownNow()
+        }
+      )
+
+      def noChangeBytes = 0
+      def noChangeIterations = 0
+      executor.scheduleAtFixedRate(
+        () -> {
+          String stderrString = stderr.toString()
+          // shutdown the task if we have a success string
+          if (stderrString.contains(jalviewjs_desktop_init_string)) {
+            f1.cancel()
+            Thread.sleep(1000)
+            executor.shutdownNow()
+          }
+          // if no change in stderr for 10s then also end
+          if (noChangeIterations >= jalviewjs_chromium_idle_timeout) {
+            executor.shutdownNow()
+          }
+          if (stderrString.length() == noChangeBytes) {
+            noChangeIterations++
+          } else {
+            noChangeBytes = stderrString.length()
+            noChangeIterations = 0
+          }
+        },
+        1, 1, TimeUnit.SECONDS)
+
+      executor.schedule(new Runnable(){
+        public void run(){
+          f1.cancel()
+          executor.shutdownNow()
+        }
+      }, timeoutms, TimeUnit.MILLISECONDS)
+
+      executor.awaitTermination(timeoutms+10000, TimeUnit.MILLISECONDS)
+      executor.shutdownNow()
+    }
+
+  }
+  
+  doLast {
+    def found = false
+    stderr.toString().eachLine { line ->
+      if (line.contains(jalviewjs_desktop_init_string)) {
+        println("Found line '"+line+"'")
+        found = true
+        return
+      }
+    }
+    if (!found) {
+      throw new GradleException("Could not find evidence of Desktop launch in JalviewJS.")
+    }
+  }
+}
+  
+
 task jalviewjs {
   group "JalviewJS"
-  description "Build the site"
+  description "Build the JalviewJS site and run the launch test"
   dependsOn jalviewjsBuildSite
+  dependsOn jalviewjsLaunchTest
 }