Releases: airvzxf/ftp-deployment-action
Release list
v2.10.0
Added
-
Multi-registry publishing for releases (LP-7). Every tag is
now pushed to three OCI registries (when the corresponding
secrets are present on the repo):Registry Image How to consume ghcr.io (always) ghcr.io/airvzxf/ftp-deployment-action:<tag>uses: airvzxf/ftp-deployment-action@v2Docker Hub (opt-in) docker.io/airvzxf/ftp-deployment-action:<tag>uses: docker://docker.io/airvzxf/ftp-deployment-action@v2AWS ECR Public (opt-in, OIDC) public.ecr.aws/airvzxf/ftp-deployment-action:<tag>uses: docker://public.ecr.aws/airvzxf/ftp-deployment-action@v2The three registries receive the same image bytes from a
singledocker buildx build(identical OCI manifest digest),
the samecosignkeyless signature, and the same
CycloneDX SBOM attestation attached viaactions/attest.
Docker Hub and ECR Public are conditional on secrets
presence — ifDOCKERHUB_USERNAME/DOCKERHUB_TOKENor
AWS_ROLE_TO_ASSUMEare unset on the repo, the pipeline emits
a::notice::and skips that registry, preserving the v2.9.0
behaviour (ghcr.io only) bit-for-bit. The one-time setup for
Docker Hub (PAT) and ECR Public (OIDC IAM role + trust
policy) is documented in README §"Publishing targets".AWS auth uses OIDC role assumption (no static AWS access
keys are stored in the repo):aws-actions/configure-aws-credentials@v4
assumes the role fromAWS_ROLE_TO_ASSUME, and
aws-actions/amazon-ecr-login@v2performs the docker login
topublic.ecr.awsusing the resulting session credentials.
The IAM trust policy is pinned tosub: repo:airvzxf/ftp-deployment-action:ref:refs/tags/v*
so only signed-tag pushes from this repo can assume it.The release pipeline (
release.yml) changes are concentrated
in two steps: themetastep now resolvesall_tags,
dockerhub_enabled, andecr_enabledoutputs based on secret
presence, andcosign sign+actions/attestare now invoked
once per enabled registry (ghcr.io is unconditional). The
smoke test deliberately pulls from ghcr.io only — the three
registries share one OCI manifest, so a ghcr.io failure
implies the same failure on docker.io and public.ecr.aws.
Documentation
- README §"Publishing targets" — new section listing the
three registries, exampleuses:lines for each, and the
one-time maintainer setup for Docker Hub (PAT + 2 secrets)
and ECR Public (OIDC IAM role with a copy-pasteable trust
policy JSON and the minimum ECR Public permission set). - README troubleshooting note — a single new sentence
pointing users who explicitly want to consume from Docker Hub
or ECR Public at thedocker://prefix pattern.
v2.9.0
What's Changed
- docs(readme): document FTPS implicit vs. explicit modes by @airvzxf in #80
- feat(ci): create draft GitHub Release in the release pipeline by @airvzxf in #82
- fix(ci): publish GitHub Release automatically on tag push by @airvzxf in #84
- feat(lock): auto-recover stale concurrency lock with timestamp sentinel by @airvzxf in #86
Full Changelog: v2.8.0...v2.9.0
v2.8.0
Added
-
Server-side concurrency lock to serialize concurrent
deployments (closes the risk fromPROPOSAL.md§5 #6: "two
simultaneous workflows to the same FTP can corrupt each
other"). The default is OFF to preserve the v2.7.0
behaviour bit-for-bit. Opt in with the new input
concurrency_lock: "true".- uses: airvzxf/ftp-deployment-action@v2 with: server: ${{ secrets.FTP_SERVER }} user: ${{ secrets.FTP_USERNAME }} password: ${{ secrets.FTP_PASSWORD }} concurrency_lock: "true"
Mechanism. Before the mirror, lftp issues
quote MKD <path>to create a sentinel directory on the FTP server.
RFC 959MKDandRMDare implemented by every FTP
server (vsftpd, proftpd, Pure-FTPd, sftp-via-FTP-gateways,
etc.) andmkdir(2)is atomic on virtually every UNIX-like
filesystem (returningEEXISTif the directory already
exists). The race window between two clients is
microseconds, and the worst-case outcome — the lock
briefly staying held by a dead runner — is caught by the
configurableconcurrency_lock_timeout. We chose
MKD/RMDbecause lftp has no built-in server-side
lockcommand (it only hasfile:use-lockfor local
files, which we verified against the lftp 4.9.3 source
and thesrc/commands.ccstatic command table).Inputs.
concurrency_lock(defaultfalse) — opt-in switch.concurrency_lock_path(default.lftp-deployment.lock)
— sentinel directory; validated with the same
validate_pathrules aslocal_dir/remote_dir
(rejects.., leading dash, control chars, and shell
metacharacters).concurrency_lock_timeout(default300) — maximum
seconds to wait for the lock when another run is
currently holding it.0means "fail immediately when
held" (no polling).concurrency_lock_poll_interval(default5) — seconds
betweenquote MKDattempts. Rejected when0(would
cause a division-by-zero in the iteration count).
Release. The lock is released in three layered ways:
(1) an inlinequote RMD <path>in the lftp-escript
right beforequit;; (2) a fallbackrun_lftp_lock_release
call in the EXIT trap (so a signal-killed lftp still
releases the lock, using the .netrc that is also about to
be removed); (3) the documentation in the README explains
the manualquote RMDrecovery path for the rare case of
a stale lock (holder died before any of the above could
run). Whenconcurrency_lockisfalse(the default),
run_lftp_lock_releaseshort-circuits on an empty lock
path and never invokes lftp, so the no-op path is bit-for-
bit identical to v2.7.0.When to use it. For the common case of a single
workflow deploying to one FTP, the GitHub Actions
concurrency:block is the recommended approach
(documented as Option A in the new README section "Concurrency /
deployment lock") because it works across runners and
regions, requires no extra inputs, and is platform-managed.
Theconcurrency_lockinput is the fallback for users who
cannot add aconcurrency:block (e.g. multiple distinct
workflows pointing to the same FTP, or deploys driven by a
tool the user does not own).
Internal
lib.sh: new functionsbuild_lock_acquire_script,
build_lock_release_script, andrun_lftp_lock_release.
All three read the new inputs via_indirection(the
project's single point of dynamic variable-name lookup);
no secondevalsite is introduced.lib.sh:run_lftp_oncenow takes two extra parameters
(lock acquire / release fragments). When the lock is
disabled, both are empty strings, so the composed
lftp-escript is bit-for-bit identical to v2.7.0.lib.sh:print_inputs_dumpnow lists the four new
inputs in both debug and non-debug modes.entrypoint.sh: the new inputs are defaulted to their
action.ymldefaults;validate_path/
validate_intare run only when the lock is enabled,
to keep the validation surface tight for the common
case (concurrency_lock=false). The EXIT trap is
extended to callrun_lftp_lock_releasebefore
removing the .netrc (so lftp can still authenticate
the release call).- Tests: 11 new bats unit tests in
tests/unit/lock.bats
cover the empty/non-empty branches, thetimeout=0
short-circuit, theceil(timeout/poll)iteration count
for both 300/5 and 300/7, the lock path verbatim pass-
through, and the no-op paths ofrun_lftp_lock_release.
3 new smoke tests intests/smoke.shverify the
default-off path emits no lock fragments in the lftp
script, and that..inconcurrency_lock_pathand
0inconcurrency_lock_poll_intervalare both
rejected with exit 2. tests/contract.shwas unchanged: it auto-discovers the
four new inputs from the newlib.sh::print_inputs_dump
list and from the staticINPUT_*references in
entrypoint.sh/lib.sh, and matches them against
action.yml(now 31 declared inputs).- Total test count: 118 unit + 33 smoke + 1 contract + 3
release-smoke = 155 tests, all green.
v2.7.0
Added
-
Auto-upload lftp log to workflow artifact on failure
(closes the second TODO inREADME.md, the "attach logs to
Workflow Artifacts" entry). A new input
upload_log_on_failure(defaulttrue) controls the
behaviour. When the action is about to exit 1, it POSTs the
captured lftp log file to the current workflow run as an
artifact namedftp-deployment-action-log-<run-attempt>, with
a 90-day retention (the maximum allowed by the public
GitHub REST API). To opt in, the user just needs to expose
the token to the step:- uses: airvzxf/ftp-deployment-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: server: ${{ secrets.FTP_SERVER }} user: ${{ secrets.FTP_USERNAME }} password: ${{ secrets.FTP_PASSWORD }} local_dir: "./public_html"
The function is fail-soft. If
GITHUB_TOKEN(or any of
GITHUB_API_URL,GITHUB_REPOSITORY,GITHUB_RUN_ID,
GITHUB_RUN_ATTEMPT) is missing, the upload is skipped
with a notice and the action still exits 1. If the upload
request itself fails (network, 4xx, 5xx), a warning is
printed and the action still exits 1. Set
upload_log_on_failure: "false"to disable the upload
entirely; the log file is always captured under
~/.lftp-logs/in the container for the runner to inspect.
Internal
Dockerfile:apk addnow also installscurl=8.21.0-r0
(the current version inalpine 3.24 main). The image
grows by ~200 KB; the trade-off is that no extra tool has
to be downloaded at runtime.lib.sh: new functionupload_log_artifact LOG_FILEin
lib.sh. It uses_indirection(the project's single
dynamic-variable-name-lookup helper) to read the GitHub-
Actions env vars, so no secondevalsite is introduced.
The Authorization header carries the token; it is never
interpolated into the URL, so the token cannot leak into
the runner log even ifcurl -vwere used.lib.sh:print_inputs_dumpnow listsupload_log_on_failure
in both debug and non-debug modes.entrypoint.sh: the new input is defaulted to empty
(thetruedefault lives inaction.yml); the function
is called only on the failure path (whenSUCCESSis
empty), between the lftp loop and the failure banner.tests/unit/upload.bats: 10 new unit tests covering the
skip branches (opt-in disabled, each missing env var,
missing log file) and the fail-soft behaviour (curl
against an unreachable host, plus a sentinel-token leak
check). The successful-upload path is not unit-tested
(it requires network and a realGITHUB_TOKEN); it is
covered manually by the README example.tests/smoke.sh: 2 new smoke tests. Test 29 confirms
upload_log_on_failure=falseskips the upload and
shows the regular failure banner. Test 30 confirms the
default (true) withoutGITHUB_TOKENstill fails
gracefully with a notice.- Contract test now sees 27 inputs (26 + 1 new) and passes
unchanged.
v2.6.0
Added
-
Pattern exclusion inputs (
excludeandexclude_delete).
Theexcludeinput takes a comma-separated list of glob
patterns and translates to lftp'smirror:excludesetting
(files matching the patterns are neither uploaded nor
deleted). Theexclude_deleteinput is independent and
translates to lftp'smirror:exclude-filesetting (files
matching the patterns are protected from--deletebut are
still uploaded if present locally). Both inputs default to
empty (no behaviour change for existing users). The same
sanitization rules that apply tolftp_settingsapply to
these inputs (control chars, backtick,$,!, more than
three;-chained directives are rejected; exit code2on
violation). Example:with: exclude: "*.map,node_modules/**,.git/**" exclude_delete: "*.log"
Closes the first TODO in
README.md(the "exclude delete
files" entry). The second TODO (auto-upload the log as a
workflow artifact) remains open and is targeted for a
follow-up release.
Internal
lib.sh:build_ftp_settingsnow appendsset mirror:exclude <value>;andset mirror:exclude-file <value>;after the 11 standard directives but before the
lftp_settingsfree-form extension, so the user can still
override vialftp_settingsif needed.lib.sh:print_inputs_dumplistsexcludeand
exclude_deletein both debug and non-debug modes.entrypoint.sh: 2 newvalidate_lftp_settingscalls (one
per input).tests/unit/parse.bats: 5 new unit tests covering the new
injection paths (default empty, onlyexclude, only
exclude_delete, both, override bylftp_settings).tests/smoke.sh: 4 new smoke tests (mirror:exclude
injection, mirror:exclude-file injection, default empty,
sanitization rejection).run_initis now variadic — it
accepts any number ofKEY=valueenv strings and a
trailing numeric timeout, instead of a single concatenated
string. The previous single-arg form is preserved for the
existing 24 tests.- Contract test now sees 26 inputs (24 + 2 new) and passes
unchanged.
v2.5.0
Added
- bats unit tests for
lib.sh(5 files, 92 tests in
tests/unit/). Each pure function in the library now has at
least one happy-path and one reject-path test that runs in
under 2 minutes without spinning up docker, alpine, or lftp.
CI gets a newunitjob that installs bats viaapt-getand
runsbats tests/unit. Amake unittarget is added for
local iteration; it skips with a notice if bats is not
installed.
Changed
- Architectural refactor (LP-1 / MP-5): split the previously
monolithicinit.sh(657 lines) into an orchestrator
(entrypoint.sh, ~190 lines) and a library of pure functions
(lib.sh, ~620 lines). The entrypoint sources the library and
drives the workflow; the library contains every validation,
parser, builder, retry helper, and reporting function. Zero
behaviour change vs. v2.4.1 — the action still accepts the
same inputs, produces the same exit codes, and the smoke tests
pass unmodified apart from the path to the entrypoint. - Deduplicate the 12 near-identical
if/elsebranches that built
FTP_SETTINGSinto a single positional-parameter-driven loop in
build_ftp_settings(lib.sh). Same keys, same defaults, same
order. - Replace the
eval "_cur=\${INPUT_${_v}-}"indirection in the
inputs dump with an explicit list of variable names plus a
single_indirectionhelper inlib.sh. Dynamic variable-name
lookup now happens in exactly one place in the entire codebase. extract_netrc_hostnow correctly handles the IPv6 form
[::1]:990(bracketed host with a port suffix) in addition to
[::1](no port). The previous\[*\])glob required the
value to end with], which silently failed on
ftps://[::1]:990and produced an empty string instead of
::1. Caught by the newextract_netrc_host: ftps://[::1]:990 -> ::1unit test.- The contract test (
tests/contract.sh) now greps both
entrypoint.shandlib.shforINPUT_*references; the
static and dynamic sets must still match the declared inputs in
action.yml.
Internal
Dockerfile:COPY entrypoint.sh lib.sh /app/,ENTRYPOINT ["/app/entrypoint.sh"].Makefile:shellcheck -x entrypoint.sh lib.sh tests/contract.sh tests/smoke.sh. The-xflag is required so shellcheck follows
theshellcheck source=lib.shdirective inentrypoint.sh.Makefile:make unittarget for the bats tests.tests/smoke.sh:INIT_REL=./entrypoint.sh(1-line path change
in the harness; the test bodies are unchanged)..github/workflows/ci.yml: newunitjob that installs bats
and runsbats tests/unit; existingshellcheckjob now
coversentrypoint.sh + lib.shand thecontractjob's name
reflects the new contract test path.
v2.4.1
Hotfix. The v2.4.0 release was cut but its job setup failed:
##[error]Unable to resolve action
sigstore/cosign-installer@v4, unable to find version v4
PR #62 (Dependabot) bumped from @v3 to @v4, but the
sigstore/cosign-installer repo does not publish a floating
v4 git ref.
Fixed
- release.yml — pin
sigstore/cosign-installerto
v4.1.2(a specific version that exists). - dependabot.yml — add a dedicated
actions-cosign-installergroup, separate from the
catch-allactions-othersgroup, so the next bump is a
conscious decision rather than a silent major-version
change that may or may not resolve.
v2.4.0
Note: v2.4.0 was cut but the release pipeline failed at
job setup (sigstore/cosign-installer@v4 is not a valid
ref). No image was published for this tag. The fix is
v2.4.1.
Added
- Release smoke test (PR #63). The release pipeline now
runstests/release-smoke.sh <just-pushed-image>between
Build and push image and Generate SBOM (CycloneDX).
The script pulls the just-pushed image from ghcr.io and
runs three cheap checks (path-traversal validation,
deprecation warning,/app/VERSIONbake). Any failure
aborts the release before SBOM / cosign run, so we never
publish or sign a broken image. Catches the v2.3.0 class
of failure (Dependabot alpine digest bump + unresolvable
lftp pin) at the build step instead of at the cosign step.
The same script is runnable locally via
make release-smoke IMAGE=<image>. Makefiletargetrelease-smokefor local validation.
Fixed
init.shwas not safe to invoke via directdocker run(PR #63, bug found while writing the release smoke
test). The Inputs received dump block accessed
${INPUT_*}without a default-empty fallback. With
set -uthis is fine in production (the GitHub Actions
runner always exports every declared input, even as
empty string) but the moment the image is run outside
GitHub Actions — exactly what the new smoke test does —
the script dies on line 192 with INPUT_DEBUG: parameter
not set. Fixed by a uniform block of POSIX
parameter-expansion defaults at the top of the script.
No change in the production code path; the fix makes
the script safe in the new directdocker runcase.- OCI license label in
release.ymlwas still
GPL-3.0(from the original release pipeline) even
thoughLICENSEwas re-licensed to AGPL-3.0 in commit
f9bfc80. Bumped to AGPL-3.0.
Changed
- Routine Dependabot bumps (PRs #54, #55, #56, #57, #62):
actions/checkoutv5 → v7 (PR #54).alpinebase image digest refresh, which moved
3.23.3 → 3.24 (PR #55) and required a follow-up lftp
pin bump to 4.9.3-r0 (PR #61, shipped as v2.3.1).docker/setup-buildx-actionv3 → v4,
docker/login-actionv3 → v4,
docker/build-push-actionv6 → v7 (PR #56).hadolint/hadolint-action3.1.0 → 3.3.0,
actions/download-artifactv4 → v8 (PR #57).sigstore/cosign-installerv3 → v4 (PR #62).
v2.3.1
Hotfix. The v2.3.0 release was cut but its image build failed
because PR #55 (Dependabot alpine base image digest bump)
moved the base image from alpine 3.23.3 to alpine 3.24, and
the new alpine dropped lftp=4.9.2-r9 (the only available
version is now lftp=4.9.3-r0).
Fixed
- Dockerfile — bump the lftp pin from
4.9.2-r9to
4.9.3-r0to match the packages available in the new
alpine 3.24 base image.ca-certificates=20260611-r0
still resolves and is unchanged.
v2.3.0
Routine dependency bumps. No user-facing change.
Changed
- PR #54 —
actions/checkoutv5 → v7. The v7 release
blocks checking out fork PRs frompull_request_target
andworkflow_runtriggers (security improvement) and
moves to ESM. Used in both CI and release workflows. - PR #55 — Alpine base image digest refreshed. The
lftp=4.9.2-r9andca-certificates=20260611-r0package
pins are unchanged; the digest bump is a routine
re-pin against the currentalpine:3.23.3content. - PR #56 —
docker/setup-buildx-actionv3 → v4,
docker/login-actionv3 → v4,docker/build-push-action
v6 → v7. All three move to Node 24 and require Actions
Runner v2.327.1+ (well past the runner version on the
defaultubuntu-latestimage at the time of writing).
Used in the release workflow. - PR #57 —
hadolint/hadolint-action3.1.0 → 3.3.0
(minor) andactions/download-artifactv4 → v8 (major).
v8 of download-artifact makes hash mismatches an error
by default (was a warning) and only unzips artifacts
whose content-type indicates a zip; the release workflow's
anchore/sbom-actionoutput is a zip, so this is
transparent for the SBOM attestation step.