feat: turnkey Postfix relay setup script for Debian/Ubuntu (username+… #706
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |