Skip to content

feat: turnkey Postfix relay setup script for Debian/Ubuntu (username+… #706

feat: turnkey Postfix relay setup script for Debian/Ubuntu (username+…

feat: turnkey Postfix relay setup script for Debian/Ubuntu (username+… #706

Workflow file for this run

name: Lint examples
# Builds/syntax-checks every example on push/PR, using the manifest files
# at the repo root (package.json, composer.json, Cargo.toml, Package.swift,
# cs-zerosmtp.csproj, pom.xml, build.gradle.kts) that point at the relevant
# single-file example.
on:
push:
branches: [main]
pull_request:
# None of these jobs need to write anything — they only check out the repo
# and run a build/syntax-check. Explicit minimal permissions (flagged by
# CodeQL as "Workflow does not contain permissions" otherwise) instead of
# relying on the default GITHUB_TOKEN scope.
permissions:
contents: read
# Three pushes to a pull request in a minute used to start three full
# matrices, and the first two kept holding runners while answering a
# question nobody was asking any more. With ~20 jobs each, plus CodeQL and
# the dependency submissions competing for the same pool, that is what put
# the later jobs in QUEUED and made a merge wait on nothing.
#
# Superseded pull-request runs are cancelled. Runs on main are not: main's
# history should carry a completed result for every commit, and nothing is
# waiting on them anyway.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Branch protection requires each of the jobs below by name, so the
# workflow itself can't use a top-level paths-ignore: a skipped *workflow*
# never reports those check names at all, and GitHub then waits on them
# forever, permanently blocking merge. A skipped *job* still reports (as
# "skipped", which satisfies a required check), so the path filtering has
# to happen per-job via `if:` instead, gated on this job's output.
changes:
runs-on: ubuntu-latest
outputs:
code: ${{ steps.check.outputs.code }}
steps:
# Full history, not fetch-depth 2. `base.sha` is the tip of `main` at
# PR-open time, not the immediate parent - once a branch picks up a
# third commit (a fixup push, a retrigger), depth 2 no longer reaches
# it and `git diff base head` fails with "bad revision" (exit 128).
# Found 2026-09-20 when PR #436 hit exactly that on its third push.
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: check
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
else
base="${{ github.event.before }}"
fi
if git diff --name-only "$base" "${{ github.sha }}" | grep -qvE '^docs/|\.md$'; then
echo "code=true" >> "$GITHUB_OUTPUT"
else
echo "code=false" >> "$GITHUB_OUTPUT"
fi
# Six pull requests reached main on 2026-08-31 and 2026-09-01 with zero
# reviews between them - among them a fork PR from an outsider, a new CI
# job, and a version bump that leads to a permanent npm publish. Merges land
# on `--squash --auto`, so checks were the only gate, and no check asked
# whether anybody had decided anything.
#
# This does not require a review on every pull request; that would stall a
# repository that merges on checks by design, and most of what ships here is
# a page a later commit can correct. It refuses a named list instead - the
# changes whose cost, when they are wrong, is not paid by a later commit.
# Replayed against those six: it catches five, each by a different class,
# and lets #396 through, which is the one that was purely reversible.
#
# Same SHA pair the `changes` job uses, and the same fetch-depth fix
# (2026-09-20): a branch three or more commits ahead of its base sha makes
# depth 2 miss the base entirely and `git diff` fails with exit 128.
board-review:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: A change in a reviewed class carries a board verdict
env:
BAZA_SHA: ${{ github.event.pull_request.base.sha }}
GLOWA_SHA: ${{ github.sha }}
TRESC_PR: ${{ github.event.pull_request.body }}
Z_FORKA: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
run: python tools/check-board-review.py
device-table:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.13'
# Installed once, before anything that needs it. It used to live inside
# the last step in this job, which worked until a step that also imports
# yaml was added above it - and then the first person to hit
# ModuleNotFoundError was an outside contributor on their first pull
# request, on a change that had nothing to do with it.
- name: Python dependencies for the checks below
run: pip install --quiet pyyaml
- name: No control characters anywhere in the repository
run: python tools/check-control-chars.py
- name: Device table and pages match data/devices.json
run: python tools/build-device-table.py --check
- name: Error pages match data/errors.json
run: python tools/build-error-pages.py --check
- name: Library error pages match data/clients.json
run: python tools/build-client-error-pages.py --check
- name: llms-full.txt matches the pages it inlines
run: python tools/build-llms-full.py --check
- name: No page contradicts a shared claim
run: python tools/check-facts.py
- name: No colour literals in runtime styles
run: python tools/check-theme-colors.py
- name: Application pages match data/apps.json
run: python tools/build-app-pages.py --check
- name: Platform pages match data/platforms.json
run: python tools/build-platform-pages.py --check
- name: Blast radius page matches data/blast-radius.json
run: python tools/build-blast-radius.py --check
- name: CLI error data matches data/errors.json
run: python tools/build-cli-errors.py --check
- name: Published data matches data/
run: python tools/build-data-endpoints.py --check
- name: llms.txt matches the site
run: python tools/build-llms-txt.py --check
- name: README Contents matches its headings
run: python tools/build-readme-toc.py --check
- name: action.yml is publishable to the Marketplace
run: python tools/check-action.py
# Same failure mode as the line above, on a different form. The MCP
# registry schema caps description at 100 characters and says so nowhere
# a person reads; the field shipped at 179 and was invalid against the
# schema it names itself.
- name: server.json is publishable to the MCP registry
run: python tools/check-mcp-server.py
# A workflow file GitHub cannot parse fails at startup with no job and
# no log, so it stays invisible until somebody notices a run named after
# a file path instead of a workflow. Two files sat like that for twelve
# runs each on 2026-08-17, carrying a 0x01 and a 0x08 that a scripted
# edit had written where a regex meant backslash-1 and backslash-b.
# Cheap to check, expensive to miss.
- name: Workflow files parse, and carry no control characters
run: python .github/check-workflows.py
# Found 2026-08-30, during review of #366: pages-deploy.yml only builds
# Jekyll on push to main, so a broken Liquid template (an unclosed
# {% if %}/{% elsif %}, a bad include) in a pull request passes every other
# check here and only breaks once it has already reached production. That
# PR was verified by hand (grepping the if/elsif/endif chain) instead of by
# a gate - which worked once, but doesn't scale to every future change in
# docs/_layouts/ or docs/_config.yml. This job runs the same build
# pages-deploy.yml runs, without the deploy steps, on every push and PR.
jekyll-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # jekyll-last-modified-at needs real per-file git log, same reason pages-deploy.yml uses it
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: docs
- name: Build with Jekyll
working-directory: docs
run: bundle exec jekyll build --destination _site --config _config.yml,_config_actions.yml
bash:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Syntax-check bash scripts
run: |
set -e
for f in bash-curl-zerosmtp.sh bash-swaks-zerosmtp.sh check-connection.sh setup-postfix-relay.sh; do
echo "Checking $f"
bash -n "$f"
done
powershell:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Syntax-check PowerShell scripts
shell: pwsh
run: |
$files = @('pwsh-zerosmtp.ps1', 'SendEmailTest_mail-tester.com.ps1', 'check-connection.ps1', 'Find-SmtpAuthExposure.ps1')
$failed = $false
foreach ($f in $files) {
Write-Host "Checking $f"
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $f), [ref]$null, [ref]$errors) | Out-Null
if ($errors.Count -gt 0) {
$failed = $true
foreach ($e in $errors) {
Write-Host "::error file=$f,line=$($e.Extent.StartLineNumber)::$($e.Message)"
}
}
}
if ($failed) { exit 1 }
python:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Syntax-check Python script
run: python -m py_compile python-zerosmtp.py
ruby:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
- name: Syntax-check Ruby script
run: ruby -c ruby-zerosmtp.rb
elixir:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: erlef/setup-beam@v1
with:
elixir-version: '1.17.3'
otp-version: '26.2.5'
- name: Syntax-check Elixir example
run: elixir -e 'Code.string_to_quoted!(File.read!("elixir-zerosmtp.exs"))'
lua:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
strategy:
# Both versions always report. If 5.1 breaks, we want to see whether 5.4
# still passes - cancelling the sibling would hide half the answer.
fail-fast: false
matrix:
# Pinned deliberately, and to two versions on purpose (measured
# 2026-08-19, #191). leafo/gh-actions-lua@v13 moved its default
# luaVersion from 5.4 to 5.5, so this job silently started building
# Lua 5.5.0 from source; 5.5.0 shipped 2025-12-15 and is in no distro
# repository yet, meaning the example was being checked against an
# interpreter no reader can install.
# 5.4 - what docs/LINUX.md tells readers to apt/dnf/zypper install.
# 5.1 - the stricter parser, and the only thing that verifies the
# "Lua 5.1+" claim in the header of lua-zerosmtp.lua. It is
# what rejects goto/::labels::/`//`/bitwise ops creeping in.
# Both build from source in well under a minute and run in parallel,
# so wall-clock cost is ~0. Safe to rename the job: `lua` is not a
# required status check on main (verified 2026-08-19; the eleven
# required contexts are bash, csharp, go, java, kotlin,
# node-and-typescript, php, powershell, python, ruby, rust). Renaming
# a *required* job would block every future pull request permanently.
luaVersion: ['5.1', '5.4']
steps:
- uses: actions/checkout@v7
- uses: leafo/gh-actions-lua@v13
with:
luaVersion: ${{ matrix.luaVersion }}
- name: Syntax-check Lua script
run: luac -p lua-zerosmtp.lua
perl:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# perl -c loads every `use`d module, so the two non-core ones have to
# be present or the syntax check fails for the wrong reason.
- name: Install Perl SMTP/TLS modules
run: |
sudo apt-get update
sudo apt-get install -y libio-socket-ssl-perl libauthen-sasl-perl
- name: Syntax-check Perl script
run: perl -c perl-zerosmtp.pl
c:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Install libcurl development headers
run: |
sudo apt-get update
sudo apt-get install -y libcurl4-openssl-dev
# -Werror on purpose: an example that compiles with warnings is an
# example somebody copies into firmware with warnings.
- name: Build C example
run: cc -std=c99 -Wall -Wextra -Werror -o /tmp/c-zerosmtp c-zerosmtp.c -lcurl
dart:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dart-lang/setup-dart@v1
- name: Install Dart dependencies
run: dart pub get
- name: Analyze Dart example
run: dart analyze dart-zerosmtp.dart
zig:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Install libcurl development headers
run: |
sudo apt-get update
sudo apt-get install -y libcurl4-openssl-dev
- uses: mlugg/setup-zig@v2
with:
# Pinned deliberately. Zig is pre-1.0 and breaks source
# compatibility between minor releases; "latest" would turn main
# red the day upstream renames something, with nothing wrong in
# the example. The file header names the same version.
version: 0.14.1
- name: Build Zig example
run: zig build-exe zig-zerosmtp.zig -lc -lcurl --cache-dir /tmp/zig-cache
ansible:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Install ansible-core
run: pip install ansible-core
# --syntax-check parses the playbook and every module reference
# without contacting a host, so it catches a typo'd module name or a
# broken Jinja expression. The credential assert is not reached.
- name: Syntax-check Ansible playbook
run: ansible-playbook --syntax-check -i localhost, ansible-zerosmtp.yml
docker-compose:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# The compose file loads .env, which is gitignored for obvious
# reasons. .env.example carries the same variable names, so copying
# it is enough to let `config` resolve everything.
- name: Provide a .env for validation
run: cp .env.example .env
- name: Validate compose file
run: docker compose -f docker-compose-zerosmtp.yml config -q
php:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- name: Syntax-check PHP scripts
run: |
php -l php-zerosmtp.php
php -l php-symfony-mailer-zerosmtp.php
go:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: '1.23'
- name: Build Go example (stdlib only, no external deps)
run: go build -o /tmp/go-zerosmtp go-zerosmtp.go
node-and-typescript:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: Install dependencies
run: npm install
- name: Syntax-check Node.js example
run: node --check node-zerosmtp.mjs
- name: Type-check TypeScript example
run: npm run typecheck
# Logika decydujaca, czy ostatni komentarz na jakims otwartym watku jest
# nasz - uzywana przez `czeka-czlowiek` i `zalegle-zewnetrzne` w
# ready-for-approval.yml. Wydzielona i testowana tutaj zamiast wewnatrz
# YAML, bo #265 pokazal, jak drogo kosztuje logika, ktorej nikt nie
# przetestowal na przypadku, ktory ma ja wywolac - patrz
# tools/unanswered-external.test.js, ktory zawiera dokladnie te dane.
unanswered-external:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: "Ostatni komentarz nie nasz: bramka i test na przypadku #265"
run: node --test tools/unanswered-external.test.js
# Logika bramki "etat mial spojrzec i nie spojrzal" - uzywana przez job
# `etat-nie-spojrzal` w ready-for-approval.yml. Wydzielona i testowana tutaj
# z tego samego powodu co powyzsza: 2026-09-01 szesc scalonych PR-ow mialo
# zero recenzji i zauwazyl to wlasciciel, a nie zaden przyrzad. Bramka,
# ktorej nikt nie przetestowal na przypadku majacym ja wywolac, jest
# ozdoba - patrz tools/seat-obligations.test.js, ktory zawiera dane tych
# szesciu PR-ow.
seat-obligations:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: "Etat nie spojrzal: bramka i test na szesciu PR-ach z 2026-09-01"
run: node --test tools/seat-obligations.test.js
# Dlug wobec kontrybutorow, ktorych prace SCALILISMY - logika z
# `tools/contributor-care.js`. Osobno od `unanswered-external` powyzej, bo
# tamten modul patrzy na watki otwarte, a scalony wniosek jest zamkniety;
# test niesie prawdziwe dane wszystkich 14 wnioskow od ludzi z zewnatrz
# i przewraca sie, gdy ktos odwroci ktorekolwiek z dwoch porownan
# decydujacych o tym, czy cokolwiek jest dlugiem.
contributor-care:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: "Scalony wklad bez odpowiedzi: bramka i testy na 14 prawdziwych wnioskach"
run: node --test tools/contributor-care.test.js
zerosmtp-mcp:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
# No dependencies here either - node's standard library and nothing else,
# which is the whole point of a server that runs on other people's
# machines with their mail settings in front of it.
- name: Protocol and tool tests
run: node --test packages/zerosmtp-mcp/test/*.test.js
# The reachability tool opens a real socket, so it is exercised locally
# rather than here: a runner with 587 blocked would turn a working tool
# into a red build, which is the wrong direction for a false alarm.
# Glama gates listing on `awesome-mcp-servers` (93,592 stars) behind a
# Dockerfile plus a passing introspection check - their bot said exactly that
# on our own pull request #13311 on 2026-08-31. `docker/mcp-registry` refuses
# us for the same missing file. Both venues take somebody's word for nothing,
# and neither should we: this builds the image and speaks MCP to it.
#
# The assertion is deliberately the protocol handshake rather than "the
# container started". A container that starts and answers nothing passes the
# first and fails the listing check - the failure that would otherwise be
# discovered by a stranger's bot rejecting us a second time.
mcp-container:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Build the image
run: docker build -t zerosmtp-mcp:ci packages/zerosmtp-mcp
- name: It answers an MCP introspection request
run: |
set -euo pipefail
{
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}'
} | timeout 30 docker run -i --rm zerosmtp-mcp:ci > out.txt
cat out.txt
grep -q serverInfo out.txt
grep -q zerosmtp out.txt
- name: It lists all four tools
run: |
set -euo pipefail
{
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}'
echo '{"jsonrpc":"2.0","method":"notifications/initialized"}'
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
} | timeout 30 docker run -i --rm zerosmtp-mcp:ci > tools.txt
cat tools.txt
for tool in lookup_smtp_error check_device_oauth relay_settings check_relay_reachable; do
grep -q "$tool" tools.txt || { echo "::error::$tool missing from tools/list"; exit 1; }
done
zerosmtp-check:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
# The package has no dependencies, so there is nothing to install and
# the tool can simply be run. This is a functional test, not a syntax
# check: a diagnostic that compiles and lies is worse than none.
- name: Help text
run: node packages/zerosmtp-check/index.js --help
- name: Run test suite
run: node --test packages/zerosmtp-check/test/zerosmtp-check.test.js
- name: Unresolvable host exits 2
run: |
set +e
node packages/zerosmtp-check/index.js this-host-does-not-exist.invalid --timeout 3000
code=$?
set -e
[ "$code" = "2" ] || { echo "::error::expected exit 2, got $code"; exit 1; }
- name: Port 25 is checked but never offered as a way to send
run: |
out=$(node packages/zerosmtp-check/index.js --port 25 --json) || true
echo "$out"
# If 25 ever starts advertising AUTH the wording below stops being
# true, and this is the step that will say so.
echo "$out" | jq -e '.ports[0].auth | length == 0' > /dev/null || { echo "::error::port 25 now offers AUTH - the report text needs rewriting"; exit 1; }
- name: Real check against mx.msgwing.com produces valid JSON
# Exit 0 (all good) and exit 1 (a port unreachable) are both correct
# behaviour and depend on the relay, so the assertion is on the
# output shape rather than on the relay being up. That keeps this
# job honest without making it flaky.
run: |
set +e
out=$(node packages/zerosmtp-check/index.js --json)
code=$?
set -e
echo "$out"
[ "$code" = "0" ] || [ "$code" = "1" ] \
|| { echo "::error::unexpected exit $code"; exit 1; }
echo "$out" | jq -e '.host and (.addresses | length > 0) and (.ports | length == 3) and has("ok")' > /dev/null
# --explain is the half of this tool that gives an answer rather than a
# measurement, so it is the half that can be confidently wrong. Each
# case below is one of the three people it exists for.
- name: Explain - a server error, matched to its cause
run: |
out=$(node packages/zerosmtp-check/index.js --explain "535 5.7.139 Authentication unsuccessful, SmtpClientAuthentication is disabled for the Tenant")
echo "$out"
echo "$out" | grep -q "tenant level" || { echo "::error::did not identify the tenant case"; exit 1; }
echo "$out" | grep -qi "December 2026" || { echo "::error::did not say whether it is still reversible"; exit 1; }
- name: Explain - a printer panel code, not a hardware fault
run: |
out=$(node packages/zerosmtp-check/index.js --explain 1102)
echo "$out"
echo "$out" | grep -qi "kyocera" || { echo "::error::device code lookup is broken"; exit 1; }
- name: Explain - a client that hides the error
run: |
out=$(node packages/zerosmtp-check/index.js --explain "curl: (67) Login denied")
echo "$out"
echo "$out" | grep -q -- "-v" || { echo "::error::did not say how to reveal the real error"; exit 1; }
- name: Explain - reads a log from a pipe
run: |
log="SASL authentication failed; server said: 535 5.7.3 Authentication unsuccessful"
out=$(echo "$log" | node packages/zerosmtp-check/index.js --explain)
echo "$out"
echo "$out" | grep -q "5.7.3" || { echo "::error::stdin path broken"; exit 1; }
- name: Explain - an unknown string exits 1 rather than inventing one
run: |
set +e
out=$(node packages/zerosmtp-check/index.js --explain "421 4.3.2 nonsense not in the corpus")
code=$?
set -e
echo "$out"
[ "$code" = "1" ] || { echo "::error::expected exit 1, got $code"; exit 1; }
# Reported by the owner running this against two Polish mail hosts:
# poczta.interia.pl advertises AUTH PLAIN LOGIN three times over and the
# tool printed it back verbatim, which reads like a finding and is not
# one. Tested by import rather than over the network, so it stays offline
# and deterministic - and the import itself is the second assertion,
# because before the main flow was guarded this opened sockets.
- name: AUTH mechanisms are de-duplicated, and importing has no side effects
run: |
node --input-type=module -e "
import { authMechanisms } from './packages/zerosmtp-check/index.js';
const CRLF = String.fromCharCode(13, 10);
const dup = authMechanisms('250-AUTH PLAIN LOGIN PLAIN LOGIN' + CRLF + '250 OK');
if (dup.join(',') !== 'PLAIN,LOGIN') { console.error('not de-duplicated: ' + dup); process.exit(1); }
if (!dup.duplicated) { console.error('duplication was not flagged'); process.exit(1); }
const clean = authMechanisms('250-AUTH LOGIN PLAIN' + CRLF + '250 OK');
if (clean.duplicated) { console.error('false positive on a clean list'); process.exit(1); }
console.log('ok: ' + dup.join(' ') + ' / ' + clean.join(' '));
"
# The action is the Marketplace listing. Publishing one that does not run
# is worse than not publishing: it is a public page that fails for
# whoever tries it. fail-on-error is false because the relay being down
# is not this test's business - what is being asserted is that the action
# runs, produces its outputs and parses its own output.
- name: The action itself runs
id: act
uses: ./
with:
host: mx.msgwing.com
ports: '25,587'
cert-expiry-days: '0'
fail-on-error: 'false'
- name: The action decodes an error without connecting
id: act_explain
uses: ./
with:
explain: "535 5.7.139 Authentication unsuccessful, SmtpClientAuthentication is disabled for the Tenant"
- name: The action produced usable outputs
run: |
echo "ok=${{ steps.act.outputs.ok }}"
echo "soonest=${{ steps.act.outputs.soonest-expiry-days }} dni"
case "${{ steps.act.outputs.ok }}" in
true|false) ;;
*) echo "::error::ok output was not a boolean"; exit 1 ;;
esac
echo '${{ steps.act.outputs.result }}' | jq -e '.ports | length == 2' > /dev/null || { echo "::error::result JSON did not carry both ports"; exit 1; }
- name: Package metadata is publishable, and ships errors.js
run: |
npm pack --dry-run 2>&1 | tee /tmp/pack.txt
grep -q "errors.js" /tmp/pack.txt || { echo "::error::errors.js missing from the tarball - --explain would crash for every user"; exit 1; }
working-directory: packages/zerosmtp-check
csharp:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '8.0.x'
- name: Build C# example
run: dotnet build cs-zerosmtp.csproj
java:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: 'temurin'
java-version: '21'
# Without caching every run re-downloads plugins and dependencies,
# which earns a 429 Too Many Requests from Maven Central and turns
# main red with nothing wrong in the code. Happened 2026-08-16.
cache: 'maven'
- name: Build Java example
# Retry in case the cache is not enough: a transient 429 or 502 from
# Central should not look like a broken example.
run: >
mvn -B compile
-Dmaven.wagon.http.retryHandler.count=3
-Dmaven.wagon.httpconnectionManager.ttlSeconds=120
kotlin:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: 'temurin'
java-version: '21'
- uses: gradle/actions/setup-gradle@v6
with:
# Kotlin 2.4.x needs a current Gradle; 8.10 is too old and fails
# with a Kotlin/Gradle compatibility error.
gradle-version: '9.6.1'
- name: Build Kotlin example
run: gradle build --no-daemon
rust:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- name: Build Rust example
run: cargo build
# Swift used to be here and is now in swift-weekly.yml. Measured over five
# runs it took 2.8-3.3 minutes while every other job finished in 0.2-0.8,
# so it alone decided how long a merge had to wait. It has also been
# removed from the required checks on main - deleting a job that branch
# protection still requires would leave every future PR waiting forever on
# a check that never reports.