Skip to content

Releases: airvzxf/ftp-deployment-action

v2.10.0

Choose a tag to compare

@github-actions github-actions released this 08 Jul 19:15
v2.10.0
f5e21a2

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@v2
    Docker Hub (opt-in) docker.io/airvzxf/ftp-deployment-action:<tag> uses: docker://docker.io/airvzxf/ftp-deployment-action@v2
    AWS ECR Public (opt-in, OIDC) public.ecr.aws/airvzxf/ftp-deployment-action:<tag> uses: docker://public.ecr.aws/airvzxf/ftp-deployment-action@v2

    The three registries receive the same image bytes from a
    single docker buildx build (identical OCI manifest digest),
    the same cosign keyless signature, and the same
    CycloneDX SBOM attestation attached via actions/attest.
    Docker Hub and ECR Public are conditional on secrets
    presence
    — if DOCKERHUB_USERNAME/DOCKERHUB_TOKEN or
    AWS_ROLE_TO_ASSUME are 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 from AWS_ROLE_TO_ASSUME, and
    aws-actions/amazon-ecr-login@v2 performs the docker login
    to public.ecr.aws using the resulting session credentials.
    The IAM trust policy is pinned to sub: 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: the meta step now resolves all_tags,
    dockerhub_enabled, and ecr_enabled outputs based on secret
    presence, and cosign sign + actions/attest are 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, example uses: 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 the docker:// prefix pattern.

v2.9.0

Choose a tag to compare

@github-actions github-actions released this 08 Jul 03:36
v2.9.0
b1c127a

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

Choose a tag to compare

@airvzxf airvzxf released this 08 Jul 01:18
v2.8.0
3c2d608

Added

  • Server-side concurrency lock to serialize concurrent
    deployments
    (closes the risk from PROPOSAL.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 959 MKD and RMD are implemented by every FTP
    server (vsftpd, proftpd, Pure-FTPd, sftp-via-FTP-gateways,
    etc.) and mkdir(2) is atomic on virtually every UNIX-like
    filesystem (returning EEXIST if 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
    configurable concurrency_lock_timeout. We chose
    MKD/RMD because lftp has no built-in server-side
    lock command (it only has file:use-lock for local
    files, which we verified against the lftp 4.9.3 source
    and the src/commands.cc static command table).

    Inputs.

    • concurrency_lock (default false) — opt-in switch.
    • concurrency_lock_path (default .lftp-deployment.lock)
      — sentinel directory; validated with the same
      validate_path rules as local_dir / remote_dir
      (rejects .., leading dash, control chars, and shell
      metacharacters).
    • concurrency_lock_timeout (default 300) — maximum
      seconds to wait for the lock when another run is
      currently holding it. 0 means "fail immediately when
      held" (no polling).
    • concurrency_lock_poll_interval (default 5) — seconds
      between quote MKD attempts. Rejected when 0 (would
      cause a division-by-zero in the iteration count).

    Release. The lock is released in three layered ways:
    (1) an inline quote RMD <path> in the lftp -e script
    right before quit;; (2) a fallback run_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 manual quote RMD recovery path for the rare case of
    a stale lock (holder died before any of the above could
    run). When concurrency_lock is false (the default),
    run_lftp_lock_release short-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.
    The concurrency_lock input is the fallback for users who
    cannot add a concurrency: 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 functions build_lock_acquire_script,
    build_lock_release_script, and run_lftp_lock_release.
    All three read the new inputs via _indirection (the
    project's single point of dynamic variable-name lookup);
    no second eval site is introduced.
  • lib.sh: run_lftp_once now takes two extra parameters
    (lock acquire / release fragments). When the lock is
    disabled, both are empty strings, so the composed
    lftp -e script is bit-for-bit identical to v2.7.0.
  • lib.sh: print_inputs_dump now lists the four new
    inputs in both debug and non-debug modes.
  • entrypoint.sh: the new inputs are defaulted to their
    action.yml defaults; validate_path /
    validate_int are 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 call run_lftp_lock_release before
    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, the timeout=0
    short-circuit, the ceil(timeout/poll) iteration count
    for both 300/5 and 300/7, the lock path verbatim pass-
    through, and the no-op paths of run_lftp_lock_release.
    3 new smoke tests in tests/smoke.sh verify the
    default-off path emits no lock fragments in the lftp
    script, and that .. in concurrency_lock_path and
    0 in concurrency_lock_poll_interval are both
    rejected with exit 2.
  • tests/contract.sh was unchanged: it auto-discovers the
    four new inputs from the new lib.sh::print_inputs_dump
    list and from the static INPUT_* 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

Choose a tag to compare

@airvzxf airvzxf released this 08 Jul 01:18
v2.7.0
413beb2

Added

  • Auto-upload lftp log to workflow artifact on failure
    (closes the second TODO in README.md, the "attach logs to
    Workflow Artifacts" entry). A new input
    upload_log_on_failure (default true) 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 named ftp-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 add now also installs curl=8.21.0-r0
    (the current version in alpine 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 function upload_log_artifact LOG_FILE in
    lib.sh. It uses _indirection (the project's single
    dynamic-variable-name-lookup helper) to read the GitHub-
    Actions env vars, so no second eval site 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 if curl -v were used.
  • lib.sh: print_inputs_dump now lists upload_log_on_failure
    in both debug and non-debug modes.
  • entrypoint.sh: the new input is defaulted to empty
    (the true default lives in action.yml); the function
    is called only on the failure path (when SUCCESS is
    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 real GITHUB_TOKEN); it is
    covered manually by the README example.
  • tests/smoke.sh: 2 new smoke tests. Test 29 confirms
    upload_log_on_failure=false skips the upload and
    shows the regular failure banner. Test 30 confirms the
    default (true) without GITHUB_TOKEN still fails
    gracefully with a notice.
  • Contract test now sees 27 inputs (26 + 1 new) and passes
    unchanged.

v2.6.0

Choose a tag to compare

@airvzxf airvzxf released this 08 Jul 01:18
v2.6.0
e6f9207

Added

  • Pattern exclusion inputs (exclude and exclude_delete).
    The exclude input takes a comma-separated list of glob
    patterns and translates to lftp's mirror:exclude setting
    (files matching the patterns are neither uploaded nor
    deleted). The exclude_delete input is independent and
    translates to lftp's mirror:exclude-file setting (files
    matching the patterns are protected from --delete but are
    still uploaded if present locally). Both inputs default to
    empty (no behaviour change for existing users). The same
    sanitization rules that apply to lftp_settings apply to
    these inputs (control chars, backtick, $, !, more than
    three ;-chained directives are rejected; exit code 2 on
    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_settings now appends set mirror:exclude <value>; and set mirror:exclude-file <value>; after the 11 standard directives but before the
    lftp_settings free-form extension, so the user can still
    override via lftp_settings if needed.
  • lib.sh: print_inputs_dump lists exclude and
    exclude_delete in both debug and non-debug modes.
  • entrypoint.sh: 2 new validate_lftp_settings calls (one
    per input).
  • tests/unit/parse.bats: 5 new unit tests covering the new
    injection paths (default empty, only exclude, only
    exclude_delete, both, override by lftp_settings).
  • tests/smoke.sh: 4 new smoke tests (mirror:exclude
    injection, mirror:exclude-file injection, default empty,
    sanitization rejection). run_init is now variadic — it
    accepts any number of KEY=value env 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

Choose a tag to compare

@airvzxf airvzxf released this 08 Jul 01:18
v2.5.0
2036870

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 new unit job that installs bats via apt-get and
    runs bats tests/unit. A make unit target 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
    monolithic init.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/else branches that built
    FTP_SETTINGS into 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 _indirection helper in lib.sh. Dynamic variable-name
    lookup now happens in exactly one place in the entire codebase.
  • extract_netrc_host now 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]:990 and produced an empty string instead of
    ::1. Caught by the new extract_netrc_host: ftps://[::1]:990 -> ::1 unit test.
  • The contract test (tests/contract.sh) now greps both
    entrypoint.sh and lib.sh for INPUT_* 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 -x flag is required so shellcheck follows
    the shellcheck source=lib.sh directive in entrypoint.sh.
  • Makefile: make unit target 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: new unit job that installs bats
    and runs bats tests/unit; existing shellcheck job now
    covers entrypoint.sh + lib.sh and the contract job's name
    reflects the new contract test path.

v2.4.1

Choose a tag to compare

@airvzxf airvzxf released this 08 Jul 01:17
v2.4.1
89ee7f3

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-installer to
    v4.1.2 (a specific version that exists).
  • dependabot.yml — add a dedicated
    actions-cosign-installer group, separate from the
    catch-all actions-others group, so the next bump is a
    conscious decision rather than a silent major-version
    change that may or may not resolve.

v2.4.0

Choose a tag to compare

@airvzxf airvzxf released this 08 Jul 01:17
v2.4.0
fdb14ad

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
    runs tests/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/VERSION bake). 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>.
  • Makefile target release-smoke for local validation.

Fixed

  • init.sh was not safe to invoke via direct docker 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 -u this 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 direct docker run case.
  • OCI license label in release.yml was still
    GPL-3.0 (from the original release pipeline) even
    though LICENSE was 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/checkout v5 → v7 (PR #54).
    • alpine base 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-action v3 → v4,
      docker/login-action v3 → v4,
      docker/build-push-action v6 → v7 (PR #56).
    • hadolint/hadolint-action 3.1.0 → 3.3.0,
      actions/download-artifact v4 → v8 (PR #57).
    • sigstore/cosign-installer v3 → v4 (PR #62).

v2.3.1

Choose a tag to compare

@airvzxf airvzxf released this 08 Jul 01:17
v2.3.1
9b2cf77

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-r9 to
    4.9.3-r0 to match the packages available in the new
    alpine 3.24 base image. ca-certificates=20260611-r0
    still resolves and is unchanged.

v2.3.0

Choose a tag to compare

@airvzxf airvzxf released this 08 Jul 01:17
v2.3.0
8b7bde1

Routine dependency bumps. No user-facing change.

Changed

  • PR #54actions/checkout v5 → v7. The v7 release
    blocks checking out fork PRs from pull_request_target
    and workflow_run triggers (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-r9 and ca-certificates=20260611-r0 package
    pins are unchanged; the digest bump is a routine
    re-pin against the current alpine:3.23.3 content.
  • PR #56docker/setup-buildx-action v3 → v4,
    docker/login-action v3 → 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
    default ubuntu-latest image at the time of writing).
    Used in the release workflow.
  • PR #57hadolint/hadolint-action 3.1.0 → 3.3.0
    (minor) and actions/download-artifact v4 → 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-action output is a zip, so this is
    transparent for the SBOM attestation step.