Publishing over FTP#
Every project in this family publishes something over FTP — a docs tree, a
built web app, or both — and every one of them reached
SamKirkland/FTP-Deploy-Action directly to do it. That produced eleven deploy
steps across eight workflows, written with three different pin spellings, five
different ways of making the built files readable, and two different answers to
the only question that matters when an upload fails: does this red the lane?
../.github/actions/deploy-over-ftp/action.yml
is the one answer. It owns the pin, owns the permission fixup, and answers that
question the same way every time: a failed publish fails the job. That is
not an input, and the section on it below says why it is not.
The eleven call sites this replaces#
Measured 2026-09-09 across the five top-level repos plus the three owned
submodules (AccelerANTgine, OxidANT, ANThology). Vendored
third_party/ANTfrastructure copies are the same two hub workflows and are not
counted twice. Line numbers are the committed ones; the step is the stable
handle. File names are the 2026-09-09 ones too: the consumer files were renamed
on 2026-09-24 (dart.yml → web.yml in jotrockenmitlocken and docs.yml in
ANThology, Linux.yml → reusable-linux.yml, dart_on_native_linux.yml and
rust_ubuntu26_04.yml → linux-x64.yml + linux-arm64.yml over a shared
reusable-linux.yml, which holds the deploy step, linux_run.yml →
reusable-linux.yml), so read the table through
the rename table.
The Pin column below is history, not the present. Every one of these steps
now reads uses: Kataglyphis/ANTfrastructure/.github/actions/deploy-over-ftp@develop
(adopted 2026-09-14; the hub’s own two workflows the same day), so the library
version and its digest live in the action and nowhere else, and the host-side
chmod/chown fixups in the last column are gone with it — the action does
that itself. What the table still answers is what each site looked like before,
which is what makes the two POLICY changes below visible rather than silent.
Repo |
Step |
Pin (before adoption) |
On upload failure (before) |
Readability fixup (before) |
|---|---|---|---|---|
ANTfrastructure |
|
SHA |
fails the lane |
host |
ANTfrastructure |
|
SHA |
fails the lane |
host |
jotrockenmitlocken |
|
tag |
fails the lane |
none |
jotrockenmitlocken |
|
tag |
fails the lane |
none |
jotrockenmitlocken |
|
tag |
fails the lane |
none |
jotrockenmitlocken |
|
tag |
fails the lane |
none |
BeschleunigerBallett |
|
tag |
|
none |
OmniAccelerANT |
|
tag |
fails the lane |
|
OxidANT |
|
tag |
|
host |
ANThology |
|
tag |
fails the lane |
host |
AccelerANTgine |
|
tag |
fails the lane |
host |
Eleven steps, seven repositories — and an eighth that publishes without owning
one of these lines: OrchestrANT calls the hub’s python-ci-linux.yml, whose
deploy-docs input defaults to true, so the hub’s own row is its row too.
Three facts fall out of that table.
The pin is not one pin. Three spellings, two commits. Two sites carry the
digest of v4.4.0; seven carry the mutable tag v4.4.0, which resolved to that
same commit 110f9186c050f71550953127052e77650219c287 when the API was asked on
2026-09-09; two carry v4.3.6, which is a different commit (a51268f6). So
nine of the eleven have no digest, and the tag under seven of them can be
moved without producing a diff anywhere in this family.
None of those nine is outside a promise it never made: all seven repositories
holding these sites extend the shared preset
("extends": ["github>Kataglyphis/ANTfrastructure"]), and default.json extends
helpers:pinGitHubActionDigests — exactly the rule that would replace those
tags with digests. The reason it has not is recorded in those renovate.json
files: the Renovate app is not installed on these repositories and will not be
(owner decision 2026-09-09), so the preset acts only when someone runs the local
CLI, renovate-local.sh (dependency-updates.md).
Since the adoption below, this action’s single digest-pinned uses: holds the
pin for every one of these sites.
The failure policy is a habit, not a decision. There are two policies across
the eleven sites, not three: nine fail the lane, and two
(BeschleunigerBallett, OxidANT) carry continue-on-error: true. Nothing in
the nine says the question was ever asked; both of the two carry a written
reason, and both reasons apply just as well to the nine. That symmetry is the
argument for deciding it once, centrally — and the decision this action makes is
not the tolerant one.
The permission fixup is copied, not shared. Six sites carry one and five
carry none, and the six are five distinct implementations: no two repos wrote
it the same way, except the hub’s own two workflows. Three of the eleven —
python-ci-linux.yml, build-docs.yml and AccelerANTgine’s linux_run.yml —
only chmod, and a chmod alone cannot do what those steps are for. See the
next section.
What the action does#
Three steps, in this order.
1. Preflight the upload tree. Validates every input, normalises local-dir
and server-dir, repairs access to the tree and then proves it. Anything
wrong here fails the job: a misconfiguration is not a flaky upload, and there is
no switch that changes that.
2. The upload, SamKirkland/FTP-Deploy-Action pinned by digest — one
uses: line for the whole family. It carries continue-on-error: true
unconditionally, and that is not tolerance: it exists so the step below can turn
a raw non-zero exit into an annotation that names the cause. The upload step
decides nothing on its own.
3. Re-raise a failed publish. Reads the upload’s outcome: success passes;
failure and cancelled exit 1 with an error annotation; and any other
value — including the empty string — also exits 1, reported as this step’s own
defect rather than the upload’s. That last branch exists because outcome is
the one link in the chain that cannot be tested off a runner (see the
verification section), and reporting a green publish as a failed one would be
the worse of the two ways to be wrong.
The preflight closes three failure modes the raw call sites carry. All three
were read off the pinned code — action v4.4.0 at digest 110f9186, whose
package.json depends on @samkirkland/ftp-deploy ^1.2.5, bundled into
dist/index.js.
An empty tree deletes the live site. deploy() diffs the local tree against
the server’s .ftp-deploy-sync-state.json and puts everything missing locally
into diffs.delete. A docs build that silently produced nothing therefore does
not “publish nothing” — it unpublishes the site. The preflight refuses to sync a
tree with no files, and it counts the way the upload will count rather than
the way find would:
**/.git*,**/.git*/**and**/node_modules/**are discounted, because those are the library’sexcludeDefaultsand this action passes noexcludeof its own. A tree holding onlynode_modules/— or only a.gitignore— looks non-empty tofindand completely empty to the library, which is precisely the unpublish this guard exists to stop.the sync-state file is discounted on top of that, and this part is not mirroring the excludes: the library does upload its state file. It filters it out of the normal upload batch and then uploads it by name at the end. A leftover state file is simply not content, so a tree holding nothing else is still an empty publish.
chmod -R 755 does not make a root-written tree publishable. The library
writes its state file into local-dir before it connects
(createLocalState, called before the ftp.Client is even constructed). A tree
the container wrote as root at mode 755 is readable by the runner and writable
by nobody but root, so the upload fails after the connection is open — and the
three sites that only chmod cannot fix that. Where passwordless sudo exists
the preflight takes ownership first, which is what OxidANT and ANThology do
by hand; where it does not, the two probes that follow decide. The mixed case —
a runner-owned directory holding root-owned files — is caught by those probes
rather than by an ownership test on the top directory, which reports that tree
as fine.
A local-dir without a trailing slash is a hard error. getDefaultSettings
throws local-dir should be a folder (must end with /), and throws the matching
server-dir message for the same reason. Both checks sit at the top of
getDefaultSettings, which the library entry point calls before it builds a
client, so at this digest neither of them is a post-connection failure —
both are an immediate throw whose message names the input but not the caller.
The hub’s python-ci-linux.yml passes its docs-artifact-path input straight
through, and that input is a consumer-supplied string. The preflight normalises
both values to exactly one trailing slash instead, stripping every trailing
slash before adding one, so site// becomes site/ rather than surviving into
site//.ftp-deploy-sync-state.json.
Why a failed upload cannot be tolerated#
There is no best-effort input, no continue-on-error a caller can reach, and
no annotation level below ::error::. The verdict step is named “Re-raise a
failed publish”, and every path through it that is not success exits 1.
That is a policy call, not an oversight, and the reasoning is short. The action cannot tell a dropped control socket from an expired password, because the upload exits the same way for both. A tolerated failure therefore does not tolerate flakiness — it tolerates any reason the publish stopped working, and a lane that has stopped publishing while staying green is the exact defect this action was written to make visible. A warning that repeats every night is not weather; it is a site nobody has read the annotation for.
outcome |
|
|---|---|
Upload succeeded |
step passes |
Upload failed or was cancelled |
|
Upload outcome unreadable |
|
Bad input ( |
job fails, before connecting |
|
job fails, before connecting |
|
job fails, before connecting |
What the two continue-on-error sites should do instead. Both wrote the
same reason: the build, the tests and the packaging all passed before the docs
push, so a flaky upload must not red the lane.
Read the job result, not the run result. A failed publish step already leaves the build and test steps green and individually reported. Nothing is lost by letting the run go red except the impression that it was fine.
Re-measure the flakiness before naming it. The preflight turns the most common “flaky FTP” — an unwritable tree, an empty docs build, a missing trailing slash — into a named failure that happens before a socket is opened. Adopt the action first; if uploads still fail after that, the failure is now specific enough to fix, and
timeoutandprotocolare inputs.Gate the publish, do not silence it. If the docs push genuinely should not run on a given branch or event, that belongs in the step’s
if:— which is whatbuild-docs.ymlalready does withgithub.event_name == 'push'. Anif:that skips is honest; acontinue-on-errorthat hides is not.Prove a new call site with
dry-run: 'true'. It reports the file count and the permission verdict without touching the server.
A call site that still passes best-effort: is not silently ignored. Because
the input does not exist, linux/scripts/lint-workflows.sh fails on it and
actionlint names the ten inputs that do.
Inputs#
Input |
Required |
Default |
Notes |
|---|---|---|---|
|
yes |
— |
Passed to the upload step’s |
|
yes |
— |
Required deliberately: the library’s default is |
|
no |
|
Empty means the library default ( |
|
no |
|
Pass-throughs, each validated in the preflight so a typo names itself instead of surfacing as a connection error. Empty is not “set to nothing”: the upstream action declares no defaults, and its parser maps an empty string to |
exclude, state-name, security and dangerous-clean-slate are deliberately
not exposed. No call site needs them, and exclude in particular is a trap:
a value passed there replaces the library’s defaults
(**/.git*, **/.git*/**, **/node_modules/**), so the obvious use — “also
skip this one folder” — quietly starts publishing .git to the web server.
Adding it later is three lines, plus a repeat of those defaults here, plus a
matching change to the preflight’s empty-tree count, which mirrors that set.
Outputs: outcome (success/failure of the upload itself — only ever
readable as failure from a step with if: always(), because the action fails
the job on that value) and file-count (publishable files found before the
sync, counted with the excludes above).
Adopting it in a workflow#
Before, with the fixup step it needs:
- name: Fix permissions for the built docs
run: |
chmod -R 755 ./docs/_build/html/
ls -la ./docs/_build/html/
- name: 📂 Sync files to domain
uses: SamKirkland/FTP-Deploy-Action@110f9186c050f71550953127052e77650219c287 # v4.4.0
with:
server: ${{ secrets.SERVER }}
username: ${{ secrets.USERNAME }}
password: ${{ secrets.PW }}
local-dir: "./docs/_build/html/"
After — the fixup, the pin and the policy all move inside:
- name: 📂 Sync files to domain
uses: Kataglyphis/ANTfrastructure/.github/actions/deploy-over-ftp@develop
with:
server: ${{ secrets.SERVER }}
username: ${{ secrets.USERNAME }}
password: ${{ secrets.PW }}
local-dir: ./docs/_build/html/
Keep the if: guards where they are — they are per-lane policy, not deploy
policy. build-docs.yml’s github.event_name == 'push' guard is what stops a
pull request publishing to production, and this action deliberately does not
second-guess it.
What this change does not do#
All eleven sites were adopted on 2026-09-14 — the hub’s two workflows
(python-ci-linux.yml, build-docs.yml) and the nine consumer steps — each one
uses: swap keeping the step’s if: and with: keys, and each dropping its own
chmod -R 755 / chown -R fixup, which the action does itself. The gap this
section used to name is closed with them: actions-selftest.yml uses: all
every action in .github/actions/ now, deploy-over-ftp among them, guarded
so it runs only on a dispatch with ftp-dry-run: true, and then as a dry run that
uploads nothing. That is what makes actionlint hold every declared
input and output on every push, so renaming one breaks the hub rather than a
consumer.
OWNER DECISION, recorded here because it is a behaviour change and not a
refactor. Two lanes tolerated a failed upload before adoption —
BeschleunigerBallett Linux.yml and OxidANT rust_ubuntu26_04.yml, both
continue-on-error: true. This action deliberately offers no such knob (a
publish that silently did not happen is the failure mode the whole page is
about), so adopting it in those two lanes means a failed upload now fails the
lane. The owner accepted that on 2026-09-14: the lanes publish documentation,
a green tick over a site that did not update is worse than a red one, and
continue-on-error was in both lanes for the same reason nobody could state
when asked. Restoring the old behaviour means not using the action there, not
adding a knob to it.
python-ci-linux.yml’s docs-artifact-path input no longer has to end in a
slash: the action normalises it.
How this was verified#
Everything below was re-run on 2026-09-09 against this tree. Nothing in this section is carried over from an earlier revision.
43 scenarios, all green. A harness parses
action.ymlwithPyYAML, pulls bothrun:blocks out of the parsed document, and executes them the way the runner invokes a composite bash step (bash --noprofile --norc -e -o pipefail <file>), with the step’s ownenv:block supplying the inputs. Covered: the happy path and itsfile-count; trailing-slash normalisation oflocal-dirandserver-dir(absent, single, multiple); a missing tree; a file passed aslocal-dir; an empty tree; a tree holding only a stale state file; a tree holding onlynode_modules/; a tree holding only.gitignoreand.git/;node_modules/not inflating the count of a real tree; empty, multi-line and all-slasheslocal-dirandserver-dir; every accepted value and one rejected value ofprotocol,log-level,dry-run,portandtimeout; and all five outcomes the verdict step can see (success,failure,cancelled, empty, unknown).Ownership cases against real ext4 fixtures, built by a root helper and then run as the unprivileged user: a root-owned mode-755 tree fails with
upload tree is not writable; a runner-owned directory holding a root-owned mode-640 file fails withupload tree is not readableand names the file; the same at mode 644 passes, because the upload needs no more than that; and asudothat exists and answers-n truebut elevates nothing fails loudly withchown’s own message rather than continuing.The upstream claims were read off the pinned bundle, downloaded at digest
110f9186:excludeDefaults = ["**/.git*", "**/.git*/**", "**/node_modules/**"]and its use as the fallback when noexcludeis passed;createLocalStatecalled before the client is constructed; both “should be a folder (must end with /)” throws sitting at the top ofgetDefaultSettings, which the entry point calls first; the state file being filtered out of the upload batch and then uploaded by name; and the default table (./,./,ftp,21,false,standard,30000,loose). The tag-to-digest resolutions in the table above came from the GitHub API the same day.linux/scripts/lint-workflows.sh—actionlint 1.7.12andshellcheck 0.11.0, both at the pins inlinux/scripts/01-core/versions.env— reportsWORKFLOW LINT OKfor this repository with the action in the tree. Run again against a throwaway repo whose only workflowuses:the action: alsoWORKFLOW LINT OK. Run a third time against that same fixture withbest-effort: 'true'added to the call:WORKFLOW LINT FAILED, with actionlint reportinginput "best-effort" is not definedand listing the ten that are. That is what proves it resolved the composite rather than skipping it — and that a call site left on the old input cannot pass the gate.shellcheck 0.11.0directly over both extractedrun:blocks: clean.
Not verified here, and named rather than implied:
No FTP server was contacted. Every upload-side claim in this file is read off the pinned bundle or exercised through the preflight, never through a connection.
The
sudosuccess path — passwordless sudo present and actually elevating — was not exercised, because the machine this ran on has no passwordless sudo. Only its failing and absent branches were.steps.<id>.outcomefor acontinue-on-errorstep inside a composite action cannot be produced off a runner. The verdict step’s handling of every value was tested by setting the variable directly; what the runner actually puts there was not. That is the whole reason the step treats an unrecognised value as its own defect instead of as a failed publish.actionlinthas no schema for action metadata files, so lintingaction.ymldirectly only reports that it is not a workflow. A composite action is graded by linting a workflow that calls it — which is what the fixture runs above do, and whatactions-selftest.ymlhas done for this action since 2026-09-14.