Repository navigation
Release Process
Source of truth: RELEASE.md, kept in the repository and copied here. The diagrams, the gate table and the publishing section were added to this copy on 8 September 2026, drawn from RELEASE.md itself,
.github/workflows/sbom.ymlandsign-release.yml, and therefs/heads/masterandrefs/tags/v*rulesets. The release history they describe - which release shipped when, on what schema, behind what gate - is in Changes Since 6.2.10. Re-copied from master 92b8f9f63 on 26 September 2026, brought up that day to the tag v6.3.4 (51e2dbda0), the release, brought up on 29 September 2026 to the document as it then stood for 6.3.6, and re-copied on 5 October 2026 from the document as the tag v6.3.6 (e3c448aa6) has it (RELEASE.md at v6.3.6): A security release during the pause says how a fix still ships - fixed and tested by hand before anything is pushed, landed through the batch's merge request, and released by the tag's pipeline, with the five steps only the owner can take and the fallback when Authenticode cannot be had; The release on GitLab, the release jobs for 6.3.5 onwards, which collect from 6.3.6 sixty-two files (the AArch64 and per-distribution packages, the AppImages, the source tarball and the Helm chart among them), each with its bundle and a.sigstore.jsoncopy, and the release's SLSA provenance, and which everybatch*branch's pipeline rehearses without publishing; 6.3.5 was the first release they made, on 29 September 2026; A release made by hand, the SHA-256 sums file 6.3.4 carries, signed with the release tags' SSH key; and steps 11 to 13 kept as the history of how the releases up to 6.3.3 were made on GitHub. Re-copied on 18 October 2026 from the document as the tag v6.3.7 (31d656c0c), the release, tagged and published on 17 October 2026, has it (RELEASE.md at v6.3.7), whose opening, status note and A security release during the pause, among other places, had been brought to the same commit on 17 October 2026, before it was tagged: no release is planned by date, and each security release is cut frommasterwith the finished workmasterholds then; since 13 October 2026 nobody in the project can approve a batch, so every batch lands on the owner's override;windows-installerwaits for both halves of the Windows suite, andreleaseforwindows-packages-arm64; the release's notes begin with a Download table made from its files, and are written with the stamp; step 9 asks the security gate before the tag, withmaster's copy of.gitlab/security-exceptions.yml; Before starting checks five things, the last batch pipeline'sdependency-scanand both Windows benches' licences among them; and A security release during the pause says that 6.3.7 did not keep the order of its steps for five of its fixes, which were public before it was released, and carries more than fixes, while the steps and their window of about a day stay the rule for every release after it. The notes marked Now at steps 9, 11, 12 and 13 are this wiki's, saying where a step stands today. 6.3.4 was built and Authenticode-signed by hand, outside either chain, and published as a GitLab release on 27 September 2026 with no Sigstore bundle, so that every server is upgraded to it by hand (Upgrading Guide). Re-copied on 19 October 2026 from the document asmasterhas it from batch49, f14412d39 (RELEASE.md at f14412d39), in which 6.3.7, the latest release, is reflected as at the tag; what it changes since the tag, most of it on the project's copy on GitHub since 18 October 2026: the status note says that GitHub was unavailable to the project from 18 September to 18 October 2026, the accountProgressiverobotstaying suspended, and that GitLab is canonical; The copy on GitHub, under The release on GitLab, says how a release is copied by hand to https://github.com/RainbowWizard-coder/hMailServer/releases once it is published, and that the old release workflows ran once there and have been disabled since; step 9 says that GitHub's own CodeQL default setup analyses the code there and that the gate does not read it; step 14 answers the issues on GitLab, the repository on GitHub having no Issues; and step 1 of On GitLab, from 6.3.5, with step 4 of A security release during the pause, names the app registration a release's federated credential goes on as the one the protected CI/CD variableAZURE_CLIENT_IDnames. Re-copied on 20 October 2026 from the document asmasterhas it from batch50, 0c0b6bae7 (RELEASE.md at 0c0b6bae7), which changes it in one place: step 10 of A security release during the pause says that the GitHub workflow a server from 6.2.28 to 6.3.3 trusts signed the releases from 6.2.19 to 6.3.3, where it said the releases up to 6.3.3. Re-copied on 21 October 2026 from the document asmasterhas it from batch51, 5e49abf06 (RELEASE.md at 5e49abf06), which changes what it says a release carries and what runs on GitHub: under The release on GitLab, step 4 says that from the release after 6.3.7release-verifywriteshmailserver-X.Y.Z.intoto.jsonl, the provenance's DSSE envelope again alone, as one line of JSON, with no second signature, and holds it to the provenance's bundle byte for byte, 161 links in all; step 6 thatrelease-publishuploads the 161 files and refuses an.intoto.jsonlthat is not the provenance's envelope byte for byte; The copy on GitHub that the file is among those copied there, the one file OpenSSF Scorecard's Signed-Releases check counts there as the release's provenance, and that GitHub Actions runs two workflows of the project's there,.github/workflows/scorecard.ymland.github/workflows/codeql.yml, which no release starts and which make nothing a release carries; and The rehearsal thatrelease-rehearsal-verifywrites and checks the file asrelease-verifydoes, where they said 160 links and files and one workflow; and step 9 of Order of operations says that CodeQL analyses the code there, GitHub's default setup at first and then the project's workflow.github/workflows/codeql.ymlin its place. The note marked Now under that step, this wiki's own, stays as it was. Re-copied on 22 October 2026 from the document asmasterhas it from batch52, 72be22b4e, onmastersince 21 October 2026 (RELEASE.md at 72be22b4e), which changes it in one place: step 7 of Order of operations stamps eighteen files,CITATION.cffat the root among them - itsversion, and itsdate-released, the date of the version'sCHANGELOG.mdheading, whichbuild/check-citation.pyholds in thecheckersjob - where it stamped seventeen. The drawing The release at a glance, this wiki's own, says eighteen files at step 7, where it said eleven, and that at step 13 winget is done by hand and Chocolatey by its CI job, where it said that the step was done by hand; the note marked Now under step 13, this wiki's own too, says the same, ashmailserver/docs/PackageManagers.mddoes since batch52, where it said that both halves of the step were done by hand. Re-copied on 23 October 2026 from the document asmasterhas it from batch53, 6fb8164c6, onmastersince 22 October 2026 (RELEASE.md at 6fb8164c6), which changes it in one place: step 6 of The release on GitLab says thatcontainer-image-startstarts the container image beside PostgreSQL and beside MariaDB and asks each for SMTP and the REST API, where it said beside PostgreSQL alone. The note marked Now under step 13, this wiki's own, says that a moderator returned the package of 20 October 2026 on 21 October, asking that its authors also credit the original hMailServer's author, Martin Knafve, and that the job sent 6.3.7 again on 22 October 2026, still without that credit, and a third time on 23 October 2026 with it, which a moderator approved at 11:56 UTC that day (the community repository's feed:Approved), where it said that the package of 20 October waited for moderation. Re-copied on 24 October 2026 from the document asmasterhas it from batch54, 9336fdd59, onmastersince 24 October 2026 (RELEASE.md at 9336fdd59), which changes it in one place: The copy on GitHub, under The release on GitLab, says that GitHub's immutable-releases setting is on, that a release published there with it on keeps its files and its tag as published, though its notes can still be edited, and so do the copies of 6.3.6 and 6.3.7, published after it was switched on during the copying of 18 October 2026, and that the copies of the releases before them, published before it, can still be changed, where it said that a release published there after the copies of 18 October 2026 cannot be changed and that the copies, published before the switch, can. The document's links to the repository's other documents point at them as 9336fdd59 has them, or at this wiki's copy where there is one. Re-copied on 26 October 2026 from the document asmasterhas it from batch55, 36bd59faa, onmastersince 26 October 2026 (RELEASE.md at 36bd59faa), which changes it in two places: step 3 of The release on GitLab says thatreleasealso waits for the start jobs of the two AArch64 packages, which start the server from each beside PostgreSQL and ask it for SMTP, the REST API and the Deck, and forlinux-image-arm64, the linux/arm64 container image, built from the generic AArch64.debbesidelinux-image's linux/amd64 one; and step 6 thatcontainer-image-startalso asks the image whether its update check knows it runs in a container (GET /api/v1/update). The document's links to the repository's other documents point at them as 36bd59faa has them, or at this wiki's copy where there is one.
The order below is not advisory. Each rule exists because violating it has already cost a release cycle or nearly shipped a defect.
Status since 18 September 2026. GitHub was unavailable to the project from
18 September 2026, when it suspended the account Progressiverobot (it stays
suspended), to 18 October 2026, and its home has been
GitLab since 22 September
2026, in the group hmailserver since 29 September 2026. GitLab is canonical:
the pipelines run there and nowhere else, a release is built, signed and
published there first, and the update feed, the package repositories and the
container registry are GitLab's. Since 18 October 2026 the project is published
on GitHub again, under the account RainbowWizard-coder
(https://github.com/RainbowWizard-coder/hMailServer): a second home, which
holds the repository and every release from 6.0.0-B1 to 6.3.7 with the same
files byte for byte; a release is copied there once it is published on GitLab
(The release on GitLab, "The copy on GitHub"), and no
pipeline of the project runs on GitHub. Steps 11 to 13
below describe the release chain as it ran on GitHub -
the draft release, the Linux packages, the SBOMs, Sigstore and Authenticode
signing, the installer smoke test and the package managers as GitHub Releases
and GitHub Actions - and are kept as the history of how releases up to 6.3.3
were made. From 6.3.5 a release is made by the tag's GitLab pipeline instead:
The release on GitLab, below, replaces steps 11 and 12
from the tag on. 6.3.4 was built and Authenticode-signed by hand, between the
two, and carries no Sigstore bundle; its sums file is signed with the release
tag's SSH key instead (A release made by hand). The bench steps run as written; step 9
reads the scan reports of the commit's GitLab pipeline where it read GitHub's
Code Scanning and Dependabot pages up to 6.3.3. Step 12's commit rule is out of
date as well: since 24 September 2026 commits are made as Christopher
Holloway, the identity of the GitLab account Progressiverobot; from 14
September 2026 up to and including 6.3.4 no approval was required, so a tree that
had passed the gate landed on master by fast-forward, with no pull request;
and from the first change after 6.3.4 every change, a release's included, lands
through a merge request that, by the rule, the other maintainer approves before
the owner merges it by fast-forward, its pipeline green (GOVERNANCE.md, Review).
A batch lands that way with python3 build/land-via-mr.py batchN (written on 26
September 2026; the first, batch41, landed through merge request !1 on 29
September 2026, with the owner's override), which refuses a merge request without
that approval unless the owner overrides it for one landing
(--land-without-approval "<reason>"), and every commit it lands is signed off
(git commit -s; CONTRIBUTING.md, "Merge requests"). Before that first landing,
a tree that had passed the gate was pushed to master directly. Since 13
October 2026 nobody in the project can approve a batch, which is the owner's own
work, so every batch lands on that override until somebody can (GOVERNANCE.md,
Review).
From 6.3.6, releases pause. No release is planned by date.
Security releases are still made, until 30 September 2027
(SECURITY.md, "While releases pause"), each cut from master with the
finished work it holds then, in the way
A security release during the pause
describes below.
For 6.3.5 onwards. Written on 26 September 2026, and first run for 6.3.5,
which tag pipeline 2892097590 published on 29 September 2026 at 10:20:45 UTC.
hmailserver/docs/ContinuousIntegration.md ("The release") lists what that run
showed - its release links' paths and its update-feed job failed, and 6.3.6
changes both - and the one-time steps the owner takes first. The jobs are in
.gitlab-ci.yml, and build/check-release-chain.py holds them to what a server
built after 6.3.3 needs.
Before the first release, and whenever the project's members or settings
change, check the protected tag rule (Settings, Repository, Protected tags):
v*, allowed to create: Maintainers, and nobody else. Every server built after
6.3.3 trusts a bundle signed by any pipeline of a v tag of this project,
whatever .gitlab-ci.yml that tag's commit has, so whoever can push such a tag
can sign a release the servers accept. Nothing in the pipeline can check this.
Release tags are created by Christopher Holloway, as Progressiverobot, alone
(the owner's decision of 26 September 2026, GOVERNANCE.md). The protected tag
rule does not say so - it names a role, Maintainers, and anyone given that role
holds it - so the release job holds what it can: it reads
.gitlab/allowed_signers as master has it, fetched by name, never as the
tag's own tree has it; it refuses every tag while that list holds any number of
keys but one; it requires the tag's signature to be that key's, by its
fingerprint; and it requires the tagged commit to be on master. These checks
run as the tagged commit has them, so a tag on a commit that is not master's
could carry a .gitlab-ci.yml or a build/ci/release-chain.py of its own
without them: they stop a mistake and a tag pushed without the owner's key.
Against a hostile commit, the protected tag rule is what holds for the Sigstore
signature every server checks: any pipeline of a protected v* tag is given a
certificate naming that tag, which servers accept. The Azure credential made
for one tag at a time (Authenticode) holds the
installer's Authenticode signature alone, which a server checks only where
UpdateRequireAuthenticode=1 is set, and never on Linux. master's list is
itself only as safe as master, which Maintainers may push to until its push
rule becomes No one, once the batch that brings the change checks has landed
(it landed on 9 October 2026, and on 14 October 2026 the rule still let
Maintainers push); after that a change to it reaches master only through a
merge request whose pipeline passed, the change checks in it (SECURITY.md, "Every change";
hmailserver/docs/ContinuousIntegration.md, "Landing on master").
-
Steps 1 to 10 below, as written, with one change: the release notes are the version's section of
CHANGELOG.md, headed## [X.Y.Z](...) - <date>, committed before the tag, becauserelease-publishmakes the release's notes from it as the tag's own tree has it and refuses a version without one - a pre-release's too,## [X.Y.Z-rc1](...).release-publishputs a Download table above the section, made from the release's own files (step 6), so the section need not say where the installers are. The installer is not built by hand (step 11): the pipeline builds it, in bench150's checkout, and refuses to if that checkout has a change of its own to a tracked file, or a file git does not track where a build would take it in (build/ci/windows-installer.ps1says which). Before the tag,python3 build/check-image-pins.pyfinds every image the CI files pin in its registry, and exits 0: a job whose image is gone fails at its pull, in the tag's pipeline as anywhere (hmailserver/docs/ContinuousIntegration.md, "Image pins"). -
Tag the release on
master, annotated and signed with the owner's key, once the commit is on GitLab'smaster- where the release commit, with the version stamp and the notes, gets through its batch's merge request (python3 build/land-via-mr.py batchN), signed off like every other - and push the tag:git tag -s vX.Y.Z -m "hMailServer X.Y.Z" git -c gpg.ssh.allowedSignersFile=.gitlab/allowed_signers verify-tag vX.Y.Z git push gitlab vX.Y.Zreleaserefuses a tag whose commit GitLab'smasterdoes not have yet, and reads the key frommaster's.gitlab/allowed_signers, so a key changed in the tagged commit alone counts for nothing.A pre-release's tag carries a suffix,
vX.Y.Z-rc1(step 12 says why). The tag's pipeline runs every gate - the checks, both suites, the package jobs, the SBOMs - andwindows-installer, which signs nothing and builds the installer on bench150 after both halves of the Windows suite,windows-gateandwindows-gate-s1vm, and afterbuild-inputs-authenticode, which holds each third-party file the installer takes to the Authenticode recordthird-party-binaries.jsonkeeps for it. -
Start
releaseonce every job before it has passed, within three days of the tag's pipeline (linux-build's artifact is kept three days; retrylinux-buildotherwise). GitLab offers the button only when every job of.gitlab-ci.ymlthat can fail the pipeline has passed -static-analysisandsemgrepamong them, which fail on a finding new to their baselines, andsemgrep-sast, which passes whatever it found and fails only when its scan does - anddependency-scanhas finished, whatever it found; it does not wait forci-image-packages,dependency-scan-verdictorrepo-hygiene, which areallow_failure, or for the manualdb-matrix. Of the package jobs of.gitlab/ci/packages-*.ymlit waits for the two whose generic files the release carries -deb-ubuntu26.04-arm64, the generic arm64.deb, andrpm-fedora44-aarch64, the generic aarch64.rpm- with their SBOM jobs, their install jobs, which install and run each package in an AArch64 container under qemu-user, and their start jobs, which start the server from each there beside PostgreSQL and ask it for SMTP, the REST API and the Deck; forrelease-packages(.gitlab/ci/release-packages.yml), which waits in turn for every other distribution release's.deband.rpmbuild and its install job, both AppImages and their test jobs,appimage-runtime-sources(the source of the libraries linked into the AppImages' runtime),appimage-bundled-sources(the source of the libraries they carry inusr/lib) and the source tarball, and gathers those files, each but the two sources files with an SBOM; forhelm-chart-package, the chart; and forlinux-image-arm64(.gitlab/ci/container-image.yml), the linux/arm64 container image, which it builds from the generic AArch64.debbesidelinux-image's linux/amd64 one. (Release may need fifty jobs at most, GitLab'sci_needs_size_limit;release-packageswaits for forty-three, forty-one of them jobsreleasedoes not need itself.) Of the Windows package jobs it waits forwindows-packages-arm64, whose ARM64 installer the release carries (step 4); a tag's pipeline has nowindows-packages-x64, since the release's x64 installer iswindows-installer's (.gitlab/ci/packages-windows.yml). It reads the four scans' reports first (build/ci/security-gate.py) and refuses while one did not finish, or found somethingSECURITY.md's policy counts ("Before a release") that no baseline and no entry of.gitlab/security-exceptions.ymlaccepts. Fix the finding, which takes a new tag on the fixed commit, or, when it is not exploitable, have the owner accept it in that file, with the reason, a reviewer and an expiry, by a merge request of its own landed onmaster(SECURITY.md, "Declaring a finding not exploitable"), and retryrelease: the job readsmaster's copy of the file, never the tagged tree's. It refuses, naming why, a tag that is lightweight, moved to another commit since the pipeline started, on a commitmasterdoes not have, or not signed by the one keymaster's.gitlab/allowed_signerslists (and every tag while that list holds more than one); aVersion.hthat does not say the tag's version, a cosign image that is notgcr.io's pinned by digest, a missing Azure setting, a missing file, a file unlike the sums its build job wrote, a compiled file without an SBOM that describes it, and a source tarballbuild/check-source-tarball.pydoes not take for this commit. It also refuses when the packagehmailserver/X.Y.Zalready holds one of the release's files with other bytes, or the installer or a bundle at all: those two are made by the signing jobs after it, and a second signing never makes the same bytes (a new timestamp, a new certificate). And it counts what GitLab and Pages will take before anything is signed: every link of the release GitLab must accept (adirect_asset_pathof at most 128 characters of letters, digits,_ . - /: the file's name without the version after one slash, which GitLab serves at/-/releases/vX.Y.Z/downloads/<name>and, while the release is the latest, at/-/releases/permalink/latest/downloads/<name>- never a path beginning/downloads/, which GitLab would serve at.../downloads/downloads/<name>, as it served 6.3.5's links until they were corrected by hand), and the update feed's site with the release in it -_redirects, with the apt and dnf repositories' rules, under the 1,000 rules and 64 KiB Pages reads, andreleases.jsonunder the 4 MiB a server reads, the repositories kept aspackage-repos'--keepkeeps them - and says the counts. Nothing has been signed or sent when it refuses.Before starting it, compare the line
hMailServer.exe <SHA-256>thatwindows-installerprinted in its job log with the hash step 8 measured, which the version'sCHANGELOG.mdsection gives (step 8). The installer carries that executable, built in bench150's checkout; a different hash means bench150's compiler, Windows SDK or libraries are not the ones step 8 used, and the notes would name an executable the release does not ship. Bring bench150 to that toolchain and retrywindows-installerfirst (ReproducibleBuilds.md). The verification sectionrelease-verifyadds to the notes names the release's files by their SHA-256 - the installer among them - and not the executable inside the installer, so the line in the job log is the only place the pipeline states it. 6.3.5's section gave no such hash, and nothing was compared. -
release-authenticode,release-sbom,release-signandrelease-verifyfollow by themselves.release-authenticodesigns the x64 installer, the ARM64 one,windows-packages-arm64's, and the PowerShell module'sHmailServer.psd1andHmailServer.psm1, whichreleasecollected from the tree, each by its own call;psgallery-publishlater sends the Gallery those signed files (CommandLinePackages.md).release-sbommakes each signed installer's own SBOM and the statement of the release's SLSA v1 provenance, which names every file by its SHA-256; the x86-64.deb's and.rpm's SBOMs come fromlinux-build, the generic AArch64 ones' fromdeb-ubuntu26.04-arm64-sbomandrpm-fedora44-aarch64-sbom, and every other package's, both AppImages' and the tarball's fromrelease-packages, and every compiled file goes out with its<file>.cdx.json(SECURITY.md, "Software bills of materials"). From 6.3.6 a release is sixty-two files, each with its bundle: the two installers, x64 and ARM64; the generic.deband.rpmfor x86-64 and for AArch64; a.debfor Debian 12 and 13 and Ubuntu 24.04 and an.rpmfor Fedora 44 and 43, EL 10 and 9, openSUSE Leap 16.0 and Tumbleweed, each for both, eighteen; the two AppImages; the source of the libraries linked into their runtime,hmailserver-X.Y.Z-appimage-runtime-sources.tar.gz, which the LGPL-2.1 and the GPL of two of them ask for and each AppImage names (hmailserver/docs/PackagesAppImage.md, "Licences"); the source of the libraries they carry inusr/lib,hmailserver-X.Y.Z-appimage-bundled-sources.tar.gz, Ubuntu's source packages, which the LGPL of GnuTLS and six others asks for and each AppImage names inbundled.txt; the source tarball; the PowerShell module'sHmailServer.psd1andHmailServer.psm1, Authenticode-signed; the Helm chart; the sums of every Linux file, the two sources files and the chart; the twenty-seven SBOMs of the compiled files and the tarball, and the tree's two. Each primary file's bundle goes out a second time as<file>.sigstore.json, andhmailserver-X.Y.Z.intoto.sigstore.jsonis the provenance, signed as the pipeline withcosign attest-blob; from the release after 6.3.7,hmailserver-X.Y.Z.intoto.jsonlis the provenance's DSSE envelope again alone, as one line of JSON, whichrelease-verifywrites from the bundle (no second signature): 161 links in all.release-signfinds every file in the provenance with cosign, andrelease-verifyholds the provenance and the copies to the files and the.intoto.jsonlto the provenance's bundle byte for byte. Readrelease-verify'sverification.md: the identity must behttps://gitlab.com/hmailserver/hmailserver//.gitlab-ci.yml@refs/tags/vX.Y.Z, the group's path the project has been at since 29 September 2026 (6.3.5, before the move, was signed ashttps://gitlab.com/Progressiverobot/hmailserver//...), and the repository id86730559, which a server requires at the group's path (hmailserver/docs/UpdateFeed.md). -
The installer smoke test, on the signed installer: download
release-signed/fromrelease-sign's artifacts to the VMware host and runbash build/ci/installer-smoke-run.sh <the previous release's installer> release-signed/hMailServer-X.Y.Z-x64.exe, thenbash build/ci/installer-smoke-run.sh release-signed/hMailServer-X.Y.Z-x64.exeon its own, a new installation, which is where the secure defaults are checked (hmailserver/docs/SecureDefaults.md) (hmailserver/docs/ContinuousIntegration.md, "The installer smoke test"). Both must pass before the next step. -
Start
release-publish, as the owner. The job uploads and creates the release with itsCI_JOB_TOKEN, which acts with the permissions of whoever started it, and the package protection rules let only an Owner push to the packagehmailserver: started by anyone else, its uploads are refused (hmailserver/docs/ContinuousIntegration.md, "What waits for the owner"). It uploads the release's 161 files - the sixty-two, their bundles, the.sigstore.jsoncopies, the provenance and its.intoto.jsonl- to the packagehmailserver/X.Y.Z- refusing, before it sends anything, a compiled file whose SBOM is missing or describes other bytes, a copy that is not its bundle, a provenance that does not name every file, a.intoto.jsonlthat is not the provenance's envelope byte for byte, a link GitLab would refuse, a site Pages or a server would not read whole, and a name the package holds with other bytes, and not sending again a file it holds with the same bytes - reads every record of each name back and holds it to the digest that was signed, creates the release with a link per file named after it, and checks that the feed the API now gives offers the release to Windows servers, to Debian and RPM servers on x86-64 and AArch64, and to the AppImages. The release's notes begin with a Download table made from its files: each Windows installer with its download address on the release page and the SHA-256 the job computed of it, the ARM64 one called a preview whilehmailserver/docs/WindowsOnArm.mdsays, in bold, "This has not been run on ARM64 hardware."; the Linux sums; the container image, while the project'sHM_PUBLISH_IMAGEis1; and how to check a download. Then come the version'sCHANGELOG.mdsection and the verification. The two installers' links are made last, ARM64 then x64, because GitLab lists a release's links newest first, so x64 and ARM64 head Packages. 6.3.6's notes began with the section and its installers were listed 24th and 25th of 26: the table and the order were given to its release by hand on 6 October 2026.pagesandpackage-repos, which wait for it, then render the feed with the release in it and deploy the site from the same pipeline (6.3.5'supdate-feed, a trigger of a pipeline onmaster, was refused by GitLab, and its feed was deployed by amasterpipeline started by hand). Check the site:python3 build/generate-update-feed.py --check --site https://updates.progressiverobot.comThe container image is pushed by hand, from this same pipeline, once the maintainers have set the project's CI/CD variable
HM_PUBLISH_IMAGEto1- not before the project has moved to the grouphmailserver(hmailserver/docs/ContainerImage.md, "Publishing"). Startcontainer-image-deb, which the pipeline shows as waiting (it refuses, exit 3, while the variable is not1; start it again once it is): it takes the release's.deband its SBOM from the published release and verifies both against their Sigstore bundles. The rest follow it:container-imagebuilds the image from that.deband pushes:X.Y.Z, and for a release without a suffix:X.Yand:latest;container-image-sbommakes its SBOM;container-image-signsigns it by digest as the same pipeline and attests the SBOM to it;container_scanningscans it; andcontainer-image-startstarts it beside PostgreSQL and beside MariaDB and asks each for SMTP, the REST API and whether its update check knows it runs in a container (GET /api/v1/update). Readcontainer-image-signed/verification.txt, the scan's report and the start test's log. A check that fails after the push leaves tags naming an image that did not pass, which the job lists: delete them. -
The package managers (step 13) are still by hand: the manifests job was not ported.
The copy on GitHub. Since 18 October 2026 a release, once release-publish has created it here, is
copied by hand to the project's GitHub repository,
https://github.com/RainbowWizard-coder/hMailServer/releases, with the same files byte for byte, each
checked against the SHA-256 the package hmailserver/X.Y.Z records for it - from the release after 6.3.7
hmailserver-X.Y.Z.intoto.jsonl among them, the one file OpenSSF Scorecard's Signed-Releases check
counts there as the release's provenance - and the same notes (6.3.6's and 6.3.7's, longer than GitHub's
limit of 125,000 characters, are cut at it there and attached whole as a file). GitHub writes . where a
file name has ~, so the per-distribution .deb files are renamed there; no server reads that copy,
since the update feed is GitLab's. GitHub's immutable-releases setting is on: a release published there
with it on keeps its files and its tag as published, though its notes can still be edited, and so do the
copies of 6.3.6 and 6.3.7, published after it was switched on during the copying of 18 October 2026; the
copies of the releases before them, published before it, can still be changed. Its tag reaches GitHub as
master does, by GitLab's push mirror, which has pushed every tag and every protected branch there after
each push since 18 October 2026 (Mirroring.md). No pipeline of the
project runs on GitHub; of its workflows GitHub Actions runs two, .github/workflows/scorecard.yml and
.github/workflows/codeql.yml, which no release starts and which make nothing a release carries. The old
release workflows sbom.yml and sign-release.yml, in the trees of the tags up to v6.3.3, ran once
there on 18 October 2026, on the copies of the releases up to 6.2.22-pre6, and have been disabled since
(Assurance Case); a tag after 6.3.3 carries neither.
The rehearsal. Every protected batch* branch's pipeline rehearses steps 3
to 4 (.gitlab/ci/release-rehearsal.yml): release-rehearsal collects every file
as release does, from the same jobs - the version is Version.h's, and the x64
installer windows-packages-x64's, built under its commit-stamped name, since
windows-installer runs for a v* tag alone - and counts what GitLab and Pages
would take; release-rehearsal-sign signs every file, makes each copy and attests
the provenance with Sigstore as the branch; release-rehearsal-verify writes the
provenance's .intoto.jsonl as release-verify does, holds every SBOM, copy and the
provenance to the files and the .intoto.jsonl to the provenance's bundle, and has
the server's own verifier read each bundle and refuse it at the identity. Servers trust v* tags alone, so what a
rehearsal signs installs nowhere, and it publishes nothing: no job of it uses the
job token GitLab gives every job, and release-chain.py refuses --rehearse with
publish. What it does not rehearse is the tag's own checks, the registry and
Authenticode; nor does it ask the security gate, although the batch's pipeline runs the
four scans the gate reads, and step 9 has the gate asked of them before the tag. A
rehearsal that fails is a release that would fail: mend it before the tag.
A tag is spent once here too. Before release-publish, a job that failed for
a reason outside the tree - a setting, a tool on bench150, the network - is
retried at the same tag once the cause is put right. A fault in the tree needs a
new commit and so a new version. A release-publish that stopped part-way, with
some files uploaded, is finished by retrying release-publish in the same
pipeline, which sends only what is not up yet; not by starting release again,
which refuses once the installer or a bundle is in the registry, since signing
again would make other bytes under the same names. After release-publish, a
release that is wrong is corrected by the next version, not by editing this one:
servers may already hold its files.
The pipeline signs every file with Sigstore and carries no SSH signature: the
key that signs the release tags is the owner's, on his own machine, and no
GitLab job can hold it. A .sig of the sums file is for a release made by hand.
From 6.3.4, a release built and published by hand - 6.3.4 itself, made after
GitHub's suspension and before the release jobs above existed, or any later one
made outside the pipeline - carries hmailserver-<v>-SHA256SUMS.txt, the
<sha256> <name> line of every file of the release but itself and its .sig, and
hmailserver-<v>-SHA256SUMS.txt.sig, an SSH signature of it made with the key
that signs the release tags: the one key .gitlab/allowed_signers lists, under
the principal christopher.j.holloway@outlook.com. It stands where the
pipeline's Sigstore bundles would. Once the sums file is written, and before
anything is uploaded, sign it with the tag's key and check the signature
against the tag's own .gitlab/allowed_signers:
ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n file hmailserver-<v>-SHA256SUMS.txt
git show v<v>:.gitlab/allowed_signers > allowed_signers
ssh-keygen -Y verify -f allowed_signers -I christopher.j.holloway@outlook.com -n file -s hmailserver-<v>-SHA256SUMS.txt.sig < hmailserver-<v>-SHA256SUMS.txt
sha256sum -c hmailserver-<v>-SHA256SUMS.txt
The namespace is file, not git's git, so a tag signature cannot be passed
off as a signature of the sums, or the other way round. Both files are
uploaded and linked like every other file of the release. SECURITY.md,
"Verifying a release", gives users the same two commands.
The tag's own pipeline, when one runs for a release made by hand, stays
blocked: pages and package-repos wait for release-publish, which such a
release never starts. The update feed then gains the release at the next
scheduled pipeline on master, or at once from a master pipeline started by
hand, which runs pages and package-repos without waiting
(ContinuousIntegration.md, "The
update feed").
From 6.3.6 no release is planned by date, but a confirmed vulnerability still
ships in a release - 6.3.7, 6.3.8 and so on - each cut from master with its
fixes and the finished work master has taken since the release before. Which
versions are supported, until when, and what gets a release is in SECURITY.md,
"While releases pause" (the maintainers' decision of 29 September 2026). A
security release is made by the tag's pipeline, as in
The release on GitLab, with the steps of
Order of operations below it. What differs is the
order in which things become public, and the steps only the owner can take.
6.3.7 did not keep that order for five of its fixes: they and their
descriptions in CHANGELOG.md were public on GitLab from 6 and 7 October 2026,
when the batches that carried them were pushed, and on master from 7 and 9
October 2026, when those batches landed, until 6.3.7 was released - the owner's
decisions of 6 and 7 October 2026 to push and land them before the release, of
12 October 2026, knowing that they were public, to hold the release for more of
the work then planned, of 13 October 2026 to make it once the work already under
way had been finished and folded into one batch, and of 15 October 2026 to add
to it the work already done for a later release, and more fixes (SECURITY.md,
"What gets a release"). 6.3.7 also carries the work that was under way on 13
October 2026 and, by the decision of 15 October 2026, the work already done for
a later release. The steps below, and their window of about a day, stay the
rule for every release after it.
Before starting, check what the release depends on is still valid: the table in SECURITY.md, "What must stay valid for a security release". In particular, check five things:
- the findings of
.gitlab/security-exceptions.ymlthat expire before the release (290 of them on 26 and 27 March 2027) have been read again, or the release job refuses; - the last pipeline of the batch the release is made from has, in its
dependency-scanjob, no Critical or High record with a fixed version that the VEX document (.gitlab/hmailserver.openvex.json) does not dismiss andmaster's.gitlab/security-exceptions.ymldoes not accept:releaseholds on every such record (build/ci/security-gate.py, step 3 of The release on GitLab), whiledependency-scanitself fails no pipeline (allow_failure) and no landing gate reads it. Fix such a record, or have the owner accept it onmasterby a merge request of its own. The advisory databases change every day, so read the tag pipeline's owndependency-scanagain before step 6; - the Artifact Signing identity validation has not lapsed (the Azure portal);
- bench150 and S1-WINBENCH, the two Windows benches, and at least one of linix,
server2 and s1-linux (the runners with the tag
linix), are up and online on GitLab, and for the batch's own pipeline, which runs jobs a tag's does not, linix's second entry,linix-systemd, and server2, the one runner with the tagkvm, as well (hmailserver/docs/ContinuousIntegration.md, "The runners"); and neither bench's Windows licence, an evaluation, ends before the tag's pipeline is done (slmgr /xpron each; the table has the dates); - the project's path, which the federated credential's subject names:
hmailserver/hmailserversince the move into the group on 29 September 2026 (Progressiverobot/hmailserverbefore it, for 6.3.5).
-
Fix it without pushing. Write the fix and its regression test, which must
fail against the build before the fix. Then run steps 2 to 9 of
Order of operations by hand, on the maintainers'
machines: the Windows suite on a bench, and the Linux build and suite on
linix by hand (
build/ci/linux-build.sh,build/ci/linux-suite.sh). Step 8c's fuzz run may cite the previous minor's run when no fuzzed parser changed. Nothing is pushed yet: the repository is public, and the fix is visible from the moment it is. -
Write the notes: the version's
CHANGELOG.mdsection, headed## [X.Y.Z](...) - <date>, committed with the stamp of step 7. The section says what was fixed, names the confidential issue, and credits the reporter. It does not say how to exploit the flaw, because it is public from the push, about a day before the release; nor does the fix's commit message, or an entry for the fix in Unreleased. -
Land it, with
python3 build/land-via-mr.py batchN: the batch's branch pipeline runs every gate, and a second person approves the merge request. Owner: if waiting for that approval would take the fix past its disclosure window, land it with--land-without-approval "<the issue and the date it is due>"; since 13 October 2026 nobody in the project can approve a batch, which is the owner's own work, so every batch lands that way, with its reason, until somebody can (GOVERNANCE.md, Review). The change is then reviewed on its merge request afterwards, when a reviewer can. -
Owner: the federated credential for the tag, made before the tag is
pushed, on the app registration that the protected CI/CD variable
AZURE_CLIENT_IDnames, as On GitLab, from 6.3.5 step 1 says. Use the project's path of that day in the subject. -
Owner: the tag. Sign it with the key in
.gitlab/allowed_signers, check it, and push it (step 2 of The release on GitLab). No one else holds that key, andreleaserefuses a tag signed by any other. -
Owner: start
releaseonce the tag's gates have passed. The owner is the only person in the project with the Maintainer role, which a manual job on a protected tag needs (the bot user of the project access tokenlaptop-gitis listed as an Owner too, and nothing can act as it, since the token is revoked: SECURITY.md, "What must stay valid for a security release").release-authenticode,release-sbom,release-signandrelease-verifythen follow by themselves. - The installer smoke test, by hand, on the signed installer (step 5 of The release on GitLab), with the previous release and on its own.
-
Owner: start
release-publish. Only an Owner's job may upload to the packagehmailserver.pagesandpackage-reposthen bring the site up to date in the same pipeline. Check it withpython3 build/generate-update-feed.py --check --site https://updates.progressiverobot.comandpython3 build/check-atom-feed.py --site https://updates.progressiverobot.com. -
Owner: remove the federated credential (
az ad app federated-credential delete, On GitLab, from 6.3.5). -
Disclose. Make the confidential issue public. Request the CVE ID from it,
if that has not been done, and quote it on the issue. Answer the reporter,
and announce the release. The update feed offers the release to every
server whose update check is on; which of them install it by the live
update, and which are upgraded by hand, the notes say (as 6.3.6's do in
"Signing, and who installs this release by the live update"). A server
from 6.2.28 to 6.3.3 is upgraded by hand, once, after the backup
hmailserver/docs/Upgrading.mddescribes: it trusts only the GitHub workflow that signed the releases from 6.2.19 to 6.3.3, so its live update refuses a release signed on GitLab, even with theUpdateFeedUrlline that points it at the feed; so is a server installed from an AArch64 package or an AppImage whose last release was 6.3.3. After that one upgrade the server trusts the group's signer and reads the feed without the line (hmailserver/docs/UpdateFeed.md, "A server from 6.2.28 to 6.3.3").
If Authenticode cannot be had in time, for example because the identity
validation has lapsed, the pipeline cannot publish. release-sbom, and every
job after it, waits for release-authenticode. The fallback is
A release made by hand, as 6.3.4 was. Its sums are
signed with the tag's key, and it carries no Sigstore bundle. No server installs
it by live update, and build/generate-update-feed.py publishes it as the
stable release only once UNSIGNED_BY_DESIGN names it. Say so in the notes, so
that administrators upgrade by hand.
Every box with a thick border is a gate: something that has stopped a release, or should have. A failure anywhere after step 7 sends you back to step 1, because the version is already stamped and the run that proves the binary is only valid for that binary.
flowchart TB
F["1. Freeze the code"] --> REV["2. Adversarial review of the<br/>complete diff - BEFORE stamping"]
REV --> FIX["3. Fix what survives.<br/>Every fix gets a negative-control test<br/>that FAILS on the pre-fix binary"]
FIX --> PF["4. wsl --shutdown if anything runs,<br/>then build/preflight-tests.ps1 -Clean<br/>and restart the service"]
PF --> RM["5. build/check-roadmap.ps1<br/>tick boxes vs section counts vs contents"]
RM --> DB["6. build/check-db-scripts.ps1<br/>+ build/check-schema-versions.ps1 (pwsh 7)"]
DB --> STAMP["7. Version stamp, eighteen files:<br/>Version.h, section_setup_64.iss,<br/>seven .csproj and nine more,<br/>CITATION.cff among them -<br/>then check-linux-version-stamp.sh<br/>and git grep the old version"]
STAMP --> BUILD["8. Build everything at the stamped version,<br/>server first: build.ps1 makes the COM wrapper<br/>(build/generate-com-wrapper.ps1) from the<br/>type library it has just built"]
BUILD --> REPRO["Second clean Release build.<br/>SHA-256 must match the first"]
REPRO --> ASSERT["8b. Full suite on the<br/>-Asserts build. Expect no HM6364.<br/>Then rebuild plain Release"]
ASSERT --> FUZZ["8c. Timed fuzz run on the release source<br/>30 minutes per harness"]
FUZZ --> GATE["9. Full regression suite on the<br/>STAMPED binary. Nothing skipped.<br/>Any later change voids it"]
GATE --> NOTES["10. Release notes - every claim<br/>checked against the diff"]
NOTES --> INST["11. ISCC on hMailServer64.iss.<br/>Never run the installer here"]
INST --> SHIP["12. Land on master, signed annotated tag;<br/>up to 6.3.3: draft release, SBOM, sign, publish<br/>on GitHub; from 6.3.5: the tag's GitLab pipeline -<br/>release, sign, verify, smoke test, publish"]
SHIP --> PM["13. The package managers,<br/>once the release is published -<br/>winget by hand, Chocolatey by its CI job"]
PM --> LOOP["14. Close the loop on every issue"]
style F stroke-width:3px
style REV stroke-width:3px
style PF stroke-width:3px
style RM stroke-width:3px
style DB stroke-width:3px
style REPRO stroke-width:3px
style ASSERT stroke-width:3px
style FUZZ stroke-width:3px
style GATE stroke-width:3px
| Gate | Script or action | What it caught, at least once |
|---|---|---|
| 2 | Human review, per dimension, findings verified by skeptics | The 6.2.15 review found a BDAT truncation defect and a fix that turned transient file locks into permanent mail loss — after the installer was built |
| 3 | Negative-control test | One of the 6.2.15 abort tests passed on both builds until it was strengthened with session-count assertions |
| 4 | preflight-tests.ps1 |
A stale ERROR log from an aborted run fails 100% of the next run's tests in fixture setup |
| 5 | check-roadmap.ps1 |
Nine sections of Roadmap.md had drifted between three hand-edited restatements of the same 750 numbers |
| 6 | check-db-scripts.ps1 |
6.2.22-pre4 shipped an installer whose database could not be created at all; the only symptom was a service that started and did not listen |
| 6 | check-schema-versions.ps1 |
Missing until 6.2.23 — proves the upgrade chain is contiguous, forward-only and present for all four backends |
| 8 | Two clean builds, same SHA-256 | A mismatch means something started embedding a timestamp or a path again despite /Brepro, /d1trimfile, /pdbaltpath and OPENSSL_NO_FILENAMES
|
| 8b |
-Asserts build, HM6364 |
An assertion violation fails the test that provoked it and names the expression, file and line |
| 8c | fuzz\run-fuzz.ps1 |
A crash, hang or violated assertion is a release blocker: minimise, fix, add the input to fuzz\regression, restart at step 1 |
| 9 | The full suite on the stamped binary | The whole point: the verdict is about this binary, and any change after it voids the run |
-
Freeze the code. No fixes land after this point without restarting at step 2.
-
Adversarial review of the complete diff — independent reviewers per dimension (delivery paths, protocol handling, UI truthfulness vs. code, docs claims vs. code), each finding verified by skeptics instructed to refute it. This happens before version stamping, not before tagging: the 6.2.15 review ran after the installer was built and both blockers it found (a BDAT truncation defect and a fix that converted transient file locks into permanent mail loss) forced rebuilds and a re-run of the full suite. Same review, an hour cheaper, if run first.
-
Fix what survives review; each defect fix gets a negative-control test. Build the pre-fix binary and confirm the new test fails against it. A test that passes on both builds proves nothing — one of the 6.2.15 abort tests did exactly that until it was strengthened with session-count assertions.
-
Pre-flight the bench: first
wsl -l --running, andwsl --shutdownif anything is up. With mirrored networking a Linux server or test run left behind inside WSL holds the suite's ports invisibly - the Windows TCP table shows nothing - and the pre-flight probes only the TLS fixtures' twelve, so it passes. An orphaned Linux bench cost the first 6.3.1 assertion gate 24 errors this way; the Linux bench is the VM now, not WSL, for this reason. Thenbuild\preflight-tests.ps1 -Cleanand restart the service, every time: a completed suite leavesPreferredHashAlgorithm=4in the ini (a fixture restores the default by writing it rather than removing the key), so the next pre-flight fails until it is cleaned, and an aborted run leaves a deliberate scanner error in the ERROR log that fails 100% of the next run's tests in fixture setup, and the TLS fixtures' twelve extra ports behind, which the pre-flight checks. Gate on its exit code rather than reading its output: a chain that started the suite after a failed pre-flight did exactly that once. -
Check the roadmap against itself:
build\check-roadmap.ps1. It reconciles every tick box inRoadmap.mdagainst the per-section counts and the contents table, because three hand-edited restatements of the same 750 numbers drift — nine sections had drifted when the check was written. -
Check the database scripts build a database:
build\check-db-scripts.ps1, run withpowershell.exeand notpwsh- SQL Server Compact's managed provider cannot load its native components under .NET on PowerShell 7, which fails as "Unable to load the native components ... of version 8876" and says nothing about the scripts. It creates a throwaway SQL CE database fromCreateTablesMSSQL.sqlusing the same splitting rules the server uses, because nothing else here does - the regression bench's database is upgraded out of band, so no local test takes the CREATE path a fresh install takes. 6.2.22-pre4 shipped an installer whose database could not be created at all, and the only symptom was a service that started and did not listen.Run
build\check-schema-versions.ps1beside it (pwsh 7, not Windows PowerShell 5.1, where it dies inMeasure-Object -Property To). It walks the registered upgrade chain and proves it is contiguous and forward-only, and that every step is present for every backend. That matters most on exactly the kind of release this checklist keeps being used for - one that moves the schema several steps past the last stable - and it was missing from this step until 6.2.23. -
Version stamp, eighteen files. The Windows nine:
Version.h(version, numeric, build),section_setup_64.iss, and all seven.csproj<Version>values. Then nine more:-
CITATION.cffat the root -version, anddate-released, the date of the version'sCHANGELOG.mdheading (step 2): how the release is cited, and what an archive that makes a record of it from the file reads.build/check-citation.pyfails thecheckersjob while either differs. -
hmailserver/source/Tools/Cli/HmailServer.psd1-ModuleVersion, the PowerShell module's version on the PowerShell Gallery. A data file cannot readVersion.h, so it is written;build/check-cli-packages.pyfails thecheckersjob while it differs. The PyPI packagehmailserver-cliis not on this list:build/ci/cli-packages.pytakes its version fromVersion.hwhen it builds it (CommandLinePackages.md). -
hmailserver/source/Tools/Cli/hmctl-VERSION, whathmctl --versionsays and its requests'User-Agentnames. The script runs as it is from/usr/binwith noVersion.hbeside it, so it is written;build/check-cli-packages.pyfails thecheckersjob while it differs. -
hmailserver/source/Server/platform/packaging/PKGBUILD-pkgver, which is also the tag the PKGBUILD fetches. It cannot be derived:makepkgparses the PKGBUILD as a shell script and never runs CMake. -
hmailserver/source/Server/platform/packaging/alpine/APKBUILD-pkgver, the release whose source tarball the Alpine package builds, for the same reason:abuildparses it as a shell script (Alpine.md). -
hmailserver/source/Server/platform/packaging/debian-src/debian/changelog- its one entry,hmailserver (<version>+ds-1) unstable, and the date line under it (SourcePackages.md). -
hmailserver/source/Server/platform/packaging/rpm/hmailserver.spec-Version:and its one%changelogentry,- <version>-1.build/check-linux-version-stamp.shholds both toVersion.h, each with a single entry. -
hmailserver/source/Tools/ImportTool.Tests/packages.lock.jsonandhmailserver/source/Tools/DBSetup.Tests/packages.lock.json- the entry for the project's own version. Regenerate each (dotnet restore --force-evaluateon that project) rather than editing it by hand. Nothing enforces this one. Measured on 10 September 2026:dotnet restore --locked-modewith a stale project-reference version exits 0, because locked mode validates the package graph and not project references - so a stale entry ships a lock file that lies about the tree, and this step is the only thing that catches it.
hmailserver/source/Server/CMakeLists.txtis not on the list, and this is the one line of this step worth reading twice: it used to be, and a stamp missed there produced Linux packages named at the wrong version, which the release jobs (build/ci/release-chain.py; the GitHub workflowsign-release.ymlup to 6.3.3) and the server's own update checker - all of which match those file names by exact equality - would simply never find. It now readsVersion.hwithfile(STRINGS)beforeproject(), so there is nothing there to stamp.Then run
build/check-linux-version-stamp.sh(Git Bash, WSL or the runner; it needs no toolchain, which is why thecheckersjob of.gitlab-ci.ymlruns it before anything is compiled, andlinux-buildagain before it packages). It proves Version.h agrees with itself, that the CMakeLists still derives rather than declares, thatpkgvermatches, and that no version literal has crept back into anything else underplatform/packaging/.Verify nothing else still carries the old version:
git grep <old-version>. What legitimately stays behind is prose of the form "new in 6.2.28", which names when something arrived and must not be moved forward. Insection_setup_64.isstheVersionInfoVersionfourth component stays0: the build number lives inVersion.hand the tag, and a build-only re-cut (the common case) must not have to touch the installer script or the seven.csprojfiles, whose versions are also<version>.0.The release's notes are written with the stamp: the version's section of
CHANGELOG.mdtakes the Unreleased section's place, under the heading step 1 of The release on GitLab gives, with an empty Unreleased section above it. Besides its Security notes the section carries### What changes on upgrade without a switch, a numbered list of every change an upgraded server makes to what it does with no setting turned, each saying what changes and, where something is to be done, what an administrator does before or after upgrading, as the sections of 6.3.4, 6.3.5 and 6.3.6 do. The list is gathered at the stamp from the entries, which say what changed where it changed; Unreleased keeps no such list.python build/renumber-upgrade-items.pynumbers it 1 to n and rewrites the section's references to its items (checkersruns it with--check). -
-
Build everything at the stamped version:
build.ps1 -Configuration Release,build-tools.ps1 -Configuration Release, ControlPaneldotnet publishto itspublish\folder (build-tools does not cover it),build-tests.ps1. Confirm the stampedFileVersiononhMailServer.exeandpublish\hMailCP.dll.The COM wrapper the tools compile against (
source\Tools\Interop\ Interop.hMailServer.dll) is not in git since 11 September 2026:build.ps1generates it from the type library it just built, andbuild-tools.ps1generates it if it is missing or stale, both throughbuild\generate-com-wrapper.ps1, which rewrites it only when the type library changed. So the order above - server, then tools - is what keeps the shipped wrapper current after an IDL change, and there is nothing to regenerate by hand and no manifest hash to update. An interface-ordering fix still has to land BEFORE the server build that the tools are built after, for the same reason as ever: the wrapper freezes the vtable layout of the type library it was made from.Then prove the build is still reproducible: build Release a second time from clean (
build\build.ps1 -Configuration Release -Clean) and compare the SHA-256 ofhMailServer.exewith the first; then build the same tree once more from clean in a second checkout at another path and compare that too. The version stamp is not committed yet, so make the checkout fromgit stash create, which records the working tree as a commit and changes nothing in it ($tree = git stash create; if (-not $tree) { $tree = 'HEAD' }, thengit worktree add --detach ..\hm-repro $tree), and runbuild\get-installer-binaries.ps1 -SystemOnlyin it, as in any new clone. All three must be identical - /Brepro, /d1trimfile (the checkout's root and the libraries' root), /pdbaltpath and OPENSSL_NO_FILENAMES in the project make the executable a function of the source, the toolchain and the libraries, and a mismatch means something has started embedding a timestamp or a path again. Two builds in one directory cannot show a path: until 26 September 2026 they were identical while a checkout elsewhere built another executable (ReproducibleBuilds.md). Put the hash in the release notes - on GitLab, the version'sCHANGELOG.mdsection, before the tag, sincerelease-verify's verification section names the installer and not the executable in it - so anyone with the same toolchain (v145, Windows SDK 10.0.26100) and library layout can check the published binary came from the published source. The two builds in the release tree rewrite thehMailServer.exestep 9 tests (build.ps1turns the project's build events off, so neither stops or starts the service), so do this before step 9, never after; the build in the second checkout writes nothing the release uses.The Linux packages keep the same promise by a different mechanism, and what is proven is narrower, so it is stated narrowly.
CMakeLists.txtpasses-ffile-prefix-map=<source>=.so no absolute path reaches the binary, and thelinux-buildjob of.gitlab-ci.yml(build/ci/linux-build.sh, as the Linux build workflow did on GitHub up to 6.3.3) exportsSOURCE_DATE_EPOCHfrom the commit's own date, which is what__DATE__, the archiver,dpkg-debandrpmbuildstamp instead of the clock. Two builds of the same tag in the same image therefore produce the samehmailserverbinary. What is not pinned is what the image holds: its Ubuntu packages are the archive's of the day it was built (on GitHub,ubuntu-latestmoved the same way), and a compiler or a Boost that moved between two builds changes the binary legitimately. The job log records the versions it built with, and the release notes say "reproducible on the runner image of the day" and give the binary's SHA-256 from that log, rather than claiming what the MSVC line above can claim. Measured on 8 September 2026: commit 8926f5c3f built twice on the hosted runners - the two attempts of run 34282935758, forty-four minutes apart, on two runner allocations - and every job's binary hashed the same both times (x86-64 clang, x86-64 GCC, AArch64 clang). Since 26 September 2026 the packages are reproducible too -CMakeLists.txtfixes their directories' and the.deb's maintainer scripts' modes andrpmbuild's clock and host name, andlinux-build.shits umask - and thelinux-reprojob checks all three for every tag beforereleasecan run (build/ci/linux-repro.sh): it builds the commit a second time withlinux-build.sh, from a copy of the tree at another path with other file modes, in another build directory, started under another umask and in another time zone, and requires the binary, the.deband the.rpmto be byte for bytelinux-build's (ReproducibleBuilds.md). The AArch64.deband.rpma release carries from 6.3.6 are the package jobs', built once and not compared.
8b. Full regression suite on the assertion build, first. Build with
build\build.ps1 -Configuration Release -Asserts - the same source, with
every HM_ASSERT kept and a violated one reported as HM6364 in the ERROR
log instead of compiled out - and run the whole suite on it. The ERROR log
is checked before each test, so a violation fails the test that provoked
it and names the expression, file and line. Expect none. Then rebuild plain
Release (step 8) before step 9: the assertion build is the dynamic-analysis
build and is never the binary that ships or is hashed.
8c. Timed fuzz run on the release source — required for every minor
release. On the build from step 8: fuzz\run-fuzz.ps1 -Target mime_message_fuzzer -Minutes 30, and the same for each other harness
listed in hmailserver/docs/Fuzzing.md. A crash, a hang or a violated
assertion is a release blocker: minimise it, fix it, add the input to
fuzz\regression, restart at step 1. Record the harnesses, the duration
and the execution count in the release notes' verification line. A patch
release may cite the previous minor's run when none of the fuzzed parsers
changed; anything else runs again.
-
Full regression suite on the stamped binary (
build\run-tests.ps1, with no-StopOnError) — every test, nothing skipped. If anything changes after this run, the run is void: rebuild and re-run. Never abort a run; if one must be stopped, expect step 4 to fail and clean up before trusting any result. At this point the reports of the commit's pipeline - dependency-scan, static-analysis, semgrep and semgrep-sast (hmailserver/docs/ContinuousIntegration.md, "Security scanning") - passbuild/ci/security-gate.py, which holds them to the thresholds inSECURITY.md("Before a release") and tomaster's copy of.gitlab/security-exceptions.yml, or the release waits. The release job runs the gate itself, but only after the tag, and readsmaster's copy, never the tagged tree's; so ask it here, before the tag, as the job will. Unpack the artifacts of those four jobs into one directory, as the jobs write them, and in a checkout of the release's commit fetchmaster's copy from GitLab and hand it to the gate:git fetch gitlab master git show FETCH_HEAD:.gitlab/security-exceptions.yml > <file> python3 build/ci/security-gate.py --artifacts <dir> --exceptions <file>It exits 1, naming each finding, while anything would hold the release. Without
--exceptionsthe gate reads the checkout's own copy, which need not bemaster's. A release made by hand runs the scans on linix against the release's commit, and the gate over what they wrote in the same way. Up to 6.3.3 these were GitHub's Code Scanning and Dependabot alert pages. Since 18 October 2026 CodeQL analyses the code there on each push tomaster, on each pull request and weekly - GitHub's default setup at first, and then the project's workflow.github/workflows/codeql.ymlin its place; the maintainers read its findings, and the gate does not.Now: the first pipeline of 26 September 2026, on master
40c7d3e0c, ran dependency-scan, static-analysis and semgrep on the project's own runners, and all three failed, which they could then do without failing the pipeline (since 6.3.6 static-analysis and semgrep fail it on a finding new to their baselines thatmaster's exceptions file does not accept, while dependency-scan still does not: Security Policy, Source code (static analysis)): GitLab refused the reports of two, and static-analysis and semgrep reported findings beyond their baselines (ContinuousIntegration.md). semgrep-sast came in the batch after it. In master's first full pipeline, 2886389460 on 27 September 2026, dependency-scan and semgrep-sast passed, and static-analysis (175 findings beyond its baseline) and semgrep (4) failed with secret_detection; batch41, the branch of 6.3.5, writes both baselines again over the release's tree and fixes the one finding of the four that was a defect (work item #21). Every release since 6.3.5 (29 September 2026) has been made by its tag's pipeline, whose release job runs the gate itself, as above; for a release made by hand the scans are run on linix and gated as above. -
README release notes — every claim checked against the diff. "Fixed" means reproduced-then-fixed or negative-control-tested; anything else is described as hardening or diagnostics. Unfixed known issues are named as unfixed.
-
Installer: ISCC on
hMailServer64.iss. Three prerequisites the script does not check:$env:hMailServerLibsmust point at the library tree,build\get-dotnet-runtime.ps1must have populatedinstallation\DotNet\with the desktop runtime the installer carries - the one releasehmailserver/docs/third-party-binaries.jsonpins, checked against the SHA-512 Microsoft publishes for it (-Verifychecks the file in place, and refuses an older download left from before the pin) - andbuild\get-installer-binaries.ps1must have placed the third-party binaries
- the MSVC runtime gathered from Visual Studio, the ADO type libraries copied
from Windows, and 7-Zip from 7-zip.org, the MariaDB client from the
build-inputs-2release and the SQL Server Compact runtime from thebuild-inputs-1release, each fetched - none of which is in git since 11 September 2026 (run it with-Verifyto check; every file is matched againsthmailserver/docs/third-party-binaries.json). Never run the installer on the dev machine — validation is the installer smoke test, which installs it on a throwaway machine and must pass before the release is published: on GitLab, step 5 of The release on GitLab (hmailserver/docs/ContinuousIntegration.md, "The installer smoke test"). Up to 6.3.3 it was the GitHub workflowinstaller-smoke.yml(in the git history atv6.3.3), dispatched by hand on a hosted runner once the draft release carried its installer:gh workflow run "Installer smoke test" -f release_tag=vX.Y.Z.
Now: build\get-installer-binaries.ps1 fetches the files of the build-inputs-1
and build-inputs-2 releases from the project's package registry on GitLab -
build-inputs-1's re-published there byte for byte, build-inputs-2's the MariaDB
client built from MariaDB's source - so a new machine can build the installer again.
12. History: how a release up to 6.3.3 was committed, tagged, published and
signed on GitHub, by the workflows that are in the git history at
v6.3.3. From 6.3.5, The release on GitLab
replaces this step from the tag on.
*Now:* since 14 September 2026 no approval is required, and since 24
September 2026 commits are made as Christopher Holloway, the identity of the
GitLab account `Progressiverobot`; up to 6.3.4 a tree that passed the gate landed on
`master` by fast-forward, with no pull request. From the first landing after 6.3.4 a
batch reaches `master` through a GitLab merge request, signed off, once somebody other
than its author has approved it, unless the owner overrides that for one landing, which
is recorded ([ContinuousIntegration.md](https://gitlab.com/hmailserver/hmailserver/-/blob/master/hmailserver/docs/ContinuousIntegration.md), "Landing on master").
`python3 build/land-via-mr.py batchN` makes that landing; the first merge request it serves is !1,
batch41's, for 6.3.5, and its branch pipeline is the gate the project's setting *Pipelines must
succeed* holds it to ([[Governance]], *Review*).
**Commit** (as chrisholloway5, no co-author trailers - history has been
rewritten once to remove them, and will be again). `master` is protected:
changes arrive by pull request with an approving review from someone other
than the last person to push, force-pushes and deletions are refused, and
an administrator's bypass exists for emergencies, not for the release flow.
So push the working branch, open the PR, have the other maintainer approve
it, and merge it with a rebase so the history stays linear and the commits
keep their own messages:
```
git push origin server-fixes-wave
gh pr create --base master --head server-fixes-wave --fill
gh pr merge --rebase # after the other maintainer's approval (GOVERNANCE.md, Review)
git pull --ff-only origin master
```
Then **tag** `vX.Y.Z` on master as an **annotated, signed** tag and push it
(tags matching `v*` are protected too: no deletion, no rewrite - a tag is
spent once):
```
git tag -s vX.Y.Z -m "hMailServer X.Y.Z"
git -c gpg.ssh.allowedSignersFile=.gitlab/allowed_signers verify-tag vX.Y.Z
git push origin vX.Y.Z
```
Signing is SSH-based (`git config gpg.format ssh` and `user.signingkey`
pointing at the public key, set once in the repository's config), and the
key must be listed in `.gitlab/allowed_signers` (`.github/allowed_signers`
until the move to GitLab) - the signing workflow
below refuses to sign anything for a tag that is lightweight or that does
not verify against that file, so an unsigned tag stops the release before
it has any assets. Every tag up to `v6.2.23-alpha1` was either lightweight or annotated but
unsigned; there was nothing to verify and nothing stopped a `v*` ref being moved.
A pre-release's tag carries a suffix - `v6.3.5-rc1`, never `v6.3.5` with
"Pre-release" in its title. The update feed and every server built after
6.3.3 tell a pre-release by that suffix alone, since neither the feed's flag
nor a title is the signer's word, and the feed does not publish a release
titled as a pre-release whose tag has none (docs/UpdateFeed.md).
Then publish **as a draft first** - this repository has
immutable releases enabled, so a published release refuses every further asset
upload and would be stuck with whatever it was created with:
```
# --prerelease ONLY for an alpha, beta or release candidate. A stable
# release must not carry it: the shipped update checker reads GitHub's
# "latest release" endpoint, which never answers with a pre-release, so a
# stable release left marked pre-release is invisible to every server in
# the field. If one is created that way, `gh release edit vX.Y.Z
# --prerelease=false` before publishing.
gh release create vX.Y.Z <installer> --draft \
--title "..." --notes-file <notes>
# The tag's own Linux run builds the six Linux artefacts - a .deb, an .rpm
# and an .AppImage per architecture - and its publish job attaches them and
# a SHA-256 sums file to this release itself. Watch it rather than
# downloading artefacts:
gh run list --workflow "Linux build" --branch vX.Y.Z
gh run watch <run-id>
# Authenticode, if a certificate has been arranged - see below. The job is
# part of "Sign release artefacts" and does nothing while the settings are
# absent, writing a notice instead.
gh workflow run "SBOM" -f release_tag=vX.Y.Z # SPDX + CycloneDX
gh workflow run "Sign release artefacts" -f tag=vX.Y.Z # LAST: signs what is attached
gh workflow run "Installer smoke test" -f release_tag=vX.Y.Z # green before publishing
# Expect: the installer, 2 .deb, 2 .rpm, 2 .AppImage, one SHA256SUMS.txt,
# 2 SBOMs, a .cosign.bundle AND a .sigstore.json (the same bytes, under the
# name OpenSSF Scorecard recognises) beside every one of them, and one
# hmailserver-vX.Y.Z.intoto.jsonl of SLSA provenance - THIRTY-ONE assets.
# Count them. This step exists because an artefact that failed to build
# goes missing quietly, and the release is immutable once published.
gh release view vX.Y.Z --json assets
gh release edit vX.Y.Z --draft=false # publish, now complete
```
**Linux packages: one release, every platform.** Pushing the tag started
the *Linux build* workflow, whose package jobs keep one `.deb` and one
`.rpm` per architecture as run artefacts for fourteen days. They join the
draft beside the installer, BEFORE the SBOM and signing steps, because
both act on whatever is attached when they run. Their names are as fixed
as the installer's: `hmailserver_<version>_amd64.deb`,
`hmailserver_<version>_arm64.deb`, `hmailserver-<version>-1.x86_64.rpm`
and `hmailserver-<version>-1.aarch64.rpm`. A Linux server's update checker
builds the one its own package manager and architecture would install and
matches it exactly, as a Windows server does the installer, so CPack's
names are uploaded as they are and never tidied. The signing workflow
lists which platforms a release carries and *warns* about a missing Linux
set rather than failing, because the Windows installer is what every
server in the field is waiting for and a Linux packaging failure must not
hold it back; a release that ships without them is a Windows-only release
and its notes say so. The AppImages are attached too, and are the one
asset here that is not for installing: an AppImage of a daemon is a way to
try the server without putting anything on the machine, and the build
script says so on its way out. The PKGBUILD is not a release asset at all -
it is built by the user's own machine from the tag.
**The installer's name is not cosmetic.** It must be exactly
`hMailServer-<tag without the leading v>-x64.exe`, with its `.cosign.bundle`
beside it. The update checker in every running server builds that name from
the tag and matches an asset on exact equality; a release named anything else
reports "a new version is available" and then "there is nothing to download"
to every installation in the field. This was broken once already, at
`v6.2.22-pre3`, whose installer was `hMailServer-6.2.22-x64.exe`. The signing
workflow now refuses a release that does not carry the expected name, so the
check happens before publication rather than after somebody notices.
Order matters twice over: the SBOMs have to be on before signing, because
cosign signs whatever is attached when it runs; and everything has to be on
before publication, because nothing can be added afterwards. 6.2.22-pre4 was
published straight away and ended up with an installer and no SBOM at all.
**A published release is immutable.** This repository has immutable
releases on: once published, a release's assets and tag cannot be added to,
changed or removed (the API answers 422 to an upload). So a signature, a
`.sigstore.json` twin or the provenance can only be attached before
publication, which is when the signing workflow attaches them; a release
that shipped before the twins and the provenance existed (6.3.1 and
earlier, and `build-inputs-1`) stays as it is, and OpenSSF Scorecard's
Signed-Releases check, which judges the five latest releases, climbs as
new releases replace them.
**The two `gh workflow run` lines are required, not belt-and-braces.** Saving a
draft does not start a workflow: GitHub does not deliver `release` events for
draft releases, which was measured here rather than assumed - creating the pre5
draft produced no run at all. The `release: created` trigger on both workflows
therefore only covers a release published without a draft, which is the path
that can no longer attach anything. Dispatching them by hand is the path that
works.
And a tag is spent once: a tag that has backed an immutable release cannot back
another, even after that release is deleted. There is no re-cutting a broken
release under the same version - it needs the next number.
-
History: the GitHub workflow
package-managers.yml, in the git history atfaca023d4; it was never ported to GitLab (hmailserver/docs/ContinuousIntegration.md, "From GitHub Actions to GitLab CI"), so the manifests are rendered and submitted by hand.The package managers, once the release is published and its installer signed. The Package managers workflow runs itself on
release: published: it downloads the installer the release actually attached, refuses it unless its Authenticode signature is valid, computes the SHA-256 from those bytes, renders the three winget manifests and the Chocolatey package from the templates inhmailserver\installation, validates them against Microsoft's published schemas and packs the.nupkg. The files are kept as an artifact of the run.Submitting them is deliberately not automatic until it has been armed once: a new package identifier is reviewed by a person at microsoft/winget-pkgs, so the FIRST submission is by hand.
hmailserver\docs\PackageManagers.mdis the walk - the fork, the token,wingetcreate submit, the Chocolatey API key - and says what to set (thePACKAGE_MANAGER_SUBMITvariable, theWINGET_TOKENandCHOCO_API_KEYsecrets) so that every release after that submits itself.The hash is computed there and never copied from a release note, because the signed installer is not the built installer: signing changes the bytes, and a package manager handing out a mismatched hash is a broken install for every user of it.
Now: the Package managers workflow was not ported to GitLab. winget's half is done by hand, and nothing has been submitted there yet, the project's submissions being paused; Chocolatey's is done in a
masterpipeline bychocolatey-release, started by hand, andchocolatey-publish, which verify the release's installer, render the package from it and push it, and which pushedhmailserver-progressiverobot6.3.7 on 20 October 2026 and, after a moderator returned it on 21 October 2026 asking that its authors also credit the original hMailServer's author, Martin Knafve, sent it again on 22 October 2026, still without that credit, and a third time on 23 October 2026, with it, which a moderator approved at 11:56 UTC that day, so thatchoco install hmailserver-progressiverobotinstalls it (PackageManagers.md);check-package-manifests.pynow checks that the installer URL is a release's file in the project's package registry on GitLab (Contributing, The checkers). -
Close the loop: answer every issue the release resolves on GitLab (and close them), update the ones it does not resolve saying so plainly; the repository on GitHub has no Issues.
Wiki addition, written on 8 September 2026 against the chain as it then ran on GitHub; the status note at the top says what has changed since, and step 12 above lists what a release carried from 6.3.1. From 6.3.5 a release is published by the tag's GitLab pipeline instead (The release on GitLab, above), and after 6.3.6, while releases pause, a security release is published the same way (A security release during the pause).
Step 12 is the part of the process with the least room to recover, because two GitHub behaviours combine: a published release refuses further asset uploads, and a tag that has backed an immutable release can never back another, even after that release is deleted. Get the order wrong and the fix is the next version number.
sequenceDiagram
autonumber
participant M as Maintainer
participant G as GitHub
participant S as SBOM workflow
participant C as Sign-release workflow
M->>G: git push origin server-fixes-wave
M->>G: gh pr create --base master --fill
M->>G: gh pr merge --rebase
Note over M,G: self-merge allowed, no reviewer required -<br/>rebase keeps history linear
M->>M: git pull --ff-only origin master
M->>M: git tag -s vX.Y.Z -m "hMailServer X.Y.Z"
M->>M: git -c gpg.ssh.allowedSignersFile=.github/allowed_signers verify-tag
M->>G: git push origin vX.Y.Z
Note over G: tags matching v* are protected:<br/>no deletion, no rewrite - a tag is spent once
M->>G: gh release create vX.Y.Z, the installer, --draft
Note over G: a DRAFT delivers no release event.<br/>Nothing starts on its own
M->>S: gh workflow run "SBOM" -f release_tag=vX.Y.Z
S->>G: attach hmailserver.spdx.json + hmailserver.cyclonedx.json
M->>C: gh workflow run "Sign release artefacts" -f tag=vX.Y.Z
C->>G: verify-tag against .github/allowed_signers
C->>C: refuse unless an asset is named exactly<br/>hMailServer-X.Y.Z-x64.exe
C->>G: cosign sign-blob --bundle for every asset
M->>G: gh release view vX.Y.Z --json assets
Note over M: expect installer + 2 SBOMs + 3 .cosign.bundle
M->>G: gh release edit vX.Y.Z --draft=false
Note over G: published - and now immutable
The order is forced twice over: the SBOMs have to be attached before signing, because cosign signs whatever is present when it runs; and everything has to be attached before publication, because nothing can be added afterwards. 6.2.22-pre4 was published straight away and ended up with an installer and no SBOM at all.
| Rule | Where it comes from | What it costs to get wrong |
|---|---|---|
| A published release refuses asset uploads (HTTP 422) | Immutable releases enabled on this repository | The release ships incomplete — an installer with no SBOM, or no signature |
| A tag is spent once | The same setting, plus the refs/tags/v* ruleset (deletion and non-fast-forward blocked) |
There is no re-cutting a broken release under the same version. It needs the next number |
A draft delivers no release event |
Measured, not assumed — creating the pre5 draft produced no run at all | The two gh workflow run lines are required, not belt-and-braces |
The asset must be named exactly hMailServer-<tag without the leading v>-x64.exe,
with its .cosign.bundle beside it. UpdateChecker in every running server builds
that name from the tag and matches an asset on exact equality, so a release named
anything else reports "a new version is available" and then "there is nothing to
download" to every installation in the field. That happened at v6.2.22-pre3, whose
installer was hMailServer-6.2.22-x64.exe under a -pre3 tag. The signing workflow
now refuses a release that does not carry the expected name, so the check happens
before publication rather than after somebody notices.
Anyone can check both halves without trusting this project's word for either. The releases up to 6.3.3 are on GitLab now, those whose files GitLab has (6.2.0 and 6.0.0-B4 are notes-only there) carrying the same files, byte for byte, as when they were published on GitHub, so these checks work exactly as they did; nothing in them contacts GitHub:
# the tag was signed by the key in .gitlab/allowed_signers (.github/ until the move to GitLab)
git -c gpg.ssh.allowedSignersFile=.gitlab/allowed_signers verify-tag v6.2.28
# the installer is the blob the workflow signed, from this repository's OIDC identity
cosign verify-blob \
--bundle hMailServer-6.2.28-x64.exe.cosign.bundle \
--certificate-identity-regexp '^https://github\.com/Progressiverobot/hmailserver/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
hMailServer-6.2.28-x64.exe
Every release from 6.2.19 to 6.3.3 was signed keylessly, through GitHub's OIDC identity, and recorded in Rekor; there is no long-lived private key to steal. Only GitHub Actions can issue that identity, so no later release can carry it; 6.3.4 carries no bundle - its sums file is signed with the release tags' SSH key instead (A release made by hand, above, and Security Policy, Verifying a release, for the two commands) - and Security Policy gives the identities the releases from 6.3.5 carry, from the project's GitLab CI: 6.3.5's at the path the project began at, and from 6.3.6 the group's, which the project moved to on 29 September 2026, with the project's id in the certificate. What Sigstore does not give you is a SmartScreen-quiet download, because Windows does not read Sigstore bundles; that is Authenticode, which the installer has carried since 6.3.1 (see Authenticode below).
Two more things a reader can check for themselves:
-
The reproducible build. With the same toolchain (v145, Windows SDK 10.0.26100)
and the README library layout, a clean Release build of the tagged source produces a
hMailServer.exewith the SHA-256 printed in that release's verification section —9E89C1E3ABF4DD36560B0B5F3515BDA3DAB8B96F00EF35DA5376A9B60176BD15for 6.2.28. -
The SBOMs. SPDX 2.3 JSON and CycloneDX JSON are attached to every release from 6.2.3; up to
6.3.3 the native dependencies (OpenSSL, Boost, libpq) were merged in by
build/merge-native-dependencies-into-sbom.ps1, which then fails if any of their PURLs is missing. From 6.3.4 every compiled file - the installer, the.deb, the.rpm- also has an SBOM of its own,<file>.cdx.json, whose subject is that file by its SHA-256 (build/asset-sbom.py verifychecks one against its file; Security Policy, Software bills of materials).
- Prefer two small releases over one large one. 6.2.15 carried IMAP semantics, delivery changes, a connection-layer rewrite and a UI expansion in one tag; its worst defect lived in the interaction between two of those themes.
-
A fix to a shared layer needs a dependents audit. Before changing
behaviour in something like
TCPConnection, enumerate who depends on the old behaviour (the BDAT reads depended on "any error ends the session"). - The public record never overstates. If it is not reproduced and negative-control-tested, it is not "fixed" — say "hardened", "instrumented" or "still open". Credibility with reporters is the project's scarcest resource.
Every release asset is signed with cosign, keylessly, and that signature is what somebody who deliberately checks can verify. It is not what Windows reads. SmartScreen and the UAC prompt read Authenticode, which the Windows installer has carried since 6.3.1 (before that they showed an unknown publisher) - so the two are complementary.
Be clear what it buys, because it is not what everybody assumes. Signing does not remove the SmartScreen warning. Microsoft's own comparison puts a signed installer in the same row as an unsigned one - flagged as unrecognised until reputation accumulates - and reputation attaches to a file that does not change, which a new 80 MB installer every few weeks never is. What signing buys is two things that are real: the elevation prompt reads the publisher's name instead of Unknown publisher, from the first signed release; and an enterprise policy that refuses unsigned binaries outright stops being a wall. For a mail server, whose users are disproportionately administrators on policy-managed Windows, the second is the one that matters.
The release-authenticode job (build/ci/windows-authenticode.ps1, on bench150)
signs the installers, and from 6.3.6 the PowerShell module, with Azure Artifact Signing through SignTool and
Microsoft's Artifact Signing plugin, with an RFC 3161 timestamp from
http://timestamp.acs.microsoft.com, and holds no secret: the job's GitLab ID
token, with the audience api://AzureADTokenExchange, is what Microsoft Entra
ID accepts through a federated credential in place of a client secret. It stops
before sending anything while a setting is missing, naming it, and when Entra
refuses the token it prints Entra's own answer, which names the subject the
token carried. What the owner creates:
-
A federated credential for each release, made for its tag alone, on the app registration that the project's protected CI/CD variable
AZURE_CLIENT_IDnames (glab api projects/86730559/variables/AZURE_CLIENT_ID), which holds the Artifact Signing Certificate Profile Signer role on the certificate profile - another registration may hold the role too, but the job signs as this one alone - before the tag is pushed: issuerhttps://gitlab.com, subjectproject_path:hmailserver/hmailserver:ref_type:tag:ref:vX.Y.Z- a GitLab ID token's subject for that tag, which Entra ID matches exactly - and audienceapi://AzureADTokenExchange. The portal has it under the app registration's Certificates & secrets, Federated credentials, Other issuer; or save this ascredential.json:{ "name": "gitlab-hmailserver-vX.Y.Z", "issuer": "https://gitlab.com", "subject": "project_path:hmailserver/hmailserver:ref_type:tag:ref:vX.Y.Z", "audiences": ["api://AzureADTokenExchange"] }az ad app federated-credential create --id <the app registration's application id> --parameters credential.jsonRemove it once the release is published (
az ad app federated-credential delete --id <application id> --federated-credential-id gitlab-hmailserver-vX.Y.Z): an app registration holds at most twenty, and one left in place would let another pipeline of the same tag sign. A pre-release's tag,vX.Y.Z-rc1, needs its own.Not a flexible federated credential matching
ref:v*(in preview on 26 September 2026), though it would save the step:v*matches every tag the protected tag rule lets a Maintainer push, and release tags are the owner's alone (The release on GitLab). A credential made for one tag, when the owner makes that tag, keeps the Authenticode signature to the owner's tags whatever the tagged commit carries, sincerelease's own checks run as the tagged commit has them. It does not hold the Sigstore signature, which any pipeline of a protectedv*tag is given and which is all a server requires unlessUpdateRequireAuthenticode=1is set.Setting the project's ID token subject to begin with its id instead of its path (the project's
ci_id_token_sub_claim_components,["project_id", "ref_type", "ref"], through GitLab's API) makes the subjectproject_id:86730559:ref_type:tag:ref:vX.Y.Z, which no rename or transfer can give to another project, and the credential's subject is then that. Sigstore does not use the subject, so this changes nothing for the bundles. -
Five CI/CD variables of the project (Settings, CI/CD, Variables), each protected, so that only a protected ref's pipeline - a
v*tag - reads them. None is a secret:AZURE_TENANT_ID,AZURE_CLIENT_ID(the app registration's application id),ARTIFACT_SIGNING_ENDPOINT(https://neu.codesigning.azure.net),ARTIFACT_SIGNING_ACCOUNTandARTIFACT_SIGNING_PROFILE. The pipeline needs no client secret; theAZURE_CLIENT_SECRETof the GitHub workflow has no counterpart here. -
The Artifact Signing Client Tools on bench150, for the machine, as an administrator. Installed on 26 September 2026, in
C:\Program Files\Microsoft\ArtifactSigningClientTools; winget's--scope machinefinds no installer for them, so the MSI is installed withmsiexecandALLUSERS=1(hmailserver/docs/ContinuousIntegration.md, "bench150", has the command).
The job then checks the signature is Valid, carries the timestamp, and left
the version resource as it was, before release-sign signs the same bytes with
Sigstore. What signing buys and what it does not is the same as below.
The authenticode job in sign-release.yml (the GitHub workflow, in the git
history at v6.3.3) signed the installer with Azure Artifact Signing - the
service formerly called Trusted Signing. The rest of this section is written as
it stood then. The job does
nothing at all until these exist. It is deliberately silent-but-visible: it
writes a notice on every release saying the installer is unsigned, and lets the
release proceed. It runs BEFORE the cosign job, which is not arbitrary: it
replaces the installer asset, and cosign has to sign the bytes that ship.
- A pay-as-you-go Azure subscription - free, trial and sponsored subscriptions are refused - with an Artifact Signing account and a certificate profile. Microsoft does the identity validation and holds the key: there is no certificate file to store, steal or expire in a drawer, and none of the 460-day reissue treadmill an owned certificate now carries. The account, its resource group and its subscription cannot be changed afterwards, so choose them as permanent.
- Repository secrets
AZURE_TENANT_ID,AZURE_CLIENT_ID,AZURE_CLIENT_SECRET, for an app registration holding the Artifact Signing Certificate Profile Signer role on that profile. The roles were renamed with the service and the old names do not resolve. Two more things the portal does not say: creating the identity validation needs the Artifact Signing Identity Verifier role on the account - Owner is not enough, because it is a data action, and without it the portal simply offers nothing to create - and the validation itself has no ARM resource type, so it is portal-only, while the certificate profile is scriptable (certificateProfiles, api-version 2025-10-13). - Repository variables
ARTIFACT_SIGNING_ENDPOINT,ARTIFACT_SIGNING_ACCOUNT,ARTIFACT_SIGNING_PROFILE. The endpoint is a full URI, not a bare host:https://neu.codesigning.azure.net, which is the action's own documented form. There is no UK region, and North Europe is the nearest of the sixteen the resource provider offers.
All six are required, and the job refuses a subset rather than signing with one: setting four of them and leaving two blank was, until the gate was rewritten, a release that failed at the Azure action and took the cosign job down with it.
Identity validation takes one to twenty business days and Microsoft states it cannot be expedited, so this is arranged once and long before a release rather than during one. It also has to be renewed, and signing stops if it lapses. It is an organisation validation against Companies House plus a one-off photo-ID check of the person applying; the verification email expires in seven days and cannot be re-sent.
The certificates this service issues live about seventy-two hours, which is why
timestamp-rfc3161 in the job is not optional however the Action's input is
marked: without a countersigned timestamp the signature stops verifying three
days later.
hMailServer 6.3.7, the latest release (17 October 2026, schema 6108) · AGPL-3.0-or-later · Repository · Mirror on GitHub · Report a documentation error (a GitLab issue naming the page)
Home — full index
Start here
1. Install and run
- Before You Install
- Installing hMailServer
- Installing on Linux
- Running in a Container
- Profiles and Environment
- The Control Panel
- The Control Deck
- Your First Domain and Mailbox
- Connecting a Mail Client
- The Webmail
- DNS for Your Domain
- Migration
2. Secure it
- Encryption and Certificates
- Database TLS
- Encryption for People
- Stopping Spam
- Stopping Viruses
- Security Hardening
- Secure Defaults
- The Service Account
- Secrets at Rest
- Sign-in and Identity
- Security Policy
3. Operate it
- Monitoring and Health
- The SNMP Agent
- Backup and Restore
- Message Storage
- Compliance and eDiscovery
- Troubleshooting
- Diagnosing Stalled Mail
- Relocating an Installation
- Upgrading hMailServer
- Upgrading Guide
- The Update Feed
- Migrating the Database Backend
- High Availability Runbook
- Warm Standby
- Runbooks Digest
4. Extend it
- Rules and Sieve
- Aliases Lists and Public Folders
- Rooms and Equipment
- Mailing Lists
- Routes and Relays
- Mail In to a Webhook
- Outbound Shaping
- The COM API and Scripting
- Event Scripts
- The REST API
- Command Line
- APIs Reference
5. Contribute to it
- Project Handbook
- Architecture
- Contributing
- Release Process
- Governance
- Assurance Case
- Regression Test Environment
- Fuzzing
- Regulatory Scope
- Third-Party Binaries
Look it up — from any journey
- Settings Reference
- Settings Overview
- Capabilities and Configuration
- Control Panel Reference
- Control Panel Pages
- Ports Reference
- Changes-Since-6210
- Version-History-to-6210
- Glossary
About this wiki
- hMailServer 6.3.7 is the latest release (17 October 2026, schema 6108)
- Pages say since 6.3.4 where a feature is new in it
- AGPL-3.0-or-later
- Repository, the one place the project is worked on
- Its mirror on GitHub, since 18 October 2026, with no Issues
- Questions: a GitLab issue with the Question template, GitHub Discussions Q&A, or the forum
- Report a documentation error: a GitLab issue naming the page