docs(changelog): cut v2.10.0 — multi-registry publishing (LP-7) #19
Workflow file for this run
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: Release | |
| # Trigger on every pushed tag matching v*.*.* (e.g. v2.0.1, v2.1.0, | |
| # v3.0.0-rc.1). Tag pushes fire on any branch; this workflow does not | |
| # filter by branch, so the release pipeline works the same on `main`, | |
| # `master`, or any feature branch used for a hotfix release. | |
| on: | |
| push: | |
| tags: | |
| - 'v[0-9]+.[0-9]+.[0-9]+*' | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: 'Tag to release (e.g. v2.0.1). Must already exist on the remote.' | |
| required: true | |
| type: string | |
| # `id-token: write` is required for two OIDC flows: cosign keyless | |
| # signing (Sigstore Fulcio) and AWS role assumption | |
| # (aws-actions/configure-aws-credentials@v4 with role-to-assume). | |
| permissions: | |
| contents: write | |
| packages: write | |
| id-token: write | |
| attestations: write | |
| jobs: | |
| release: | |
| name: Build, sign and publish | |
| runs-on: ubuntu-latest | |
| # The pipeline tolerates the absence of DOCKERHUB_* and | |
| # AWS_ROLE_TO_ASSUME secrets: in that case the corresponding | |
| # registry is skipped (a ::notice:: is emitted). ghcr.io is | |
| # always published because GITHUB_TOKEN is automatic. Set | |
| # both Docker Hub PAT secrets and AWS_ROLE_TO_ASSUME on the | |
| # repo to publish to all three registries on every release. | |
| # See README section "Publishing targets" for the one-time | |
| # setup. | |
| steps: | |
| # v2.10.0+: this step resolves the tag/version (unchanged | |
| # from v2.9.0) AND decides which secondary registries are | |
| # enabled, based on the presence of | |
| # DOCKERHUB_USERNAME+DOCKERHUB_TOKEN and AWS_ROLE_TO_ASSUME | |
| # secrets. The `all_tags` output is a heredoc YAML literal | |
| # the build-push step consumes verbatim; conditional | |
| # registries contribute zero lines because we only append | |
| # when their flag is on. | |
| - name: Resolve tag, version and enabled registries | |
| id: meta | |
| env: | |
| DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} | |
| DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} | |
| AWS_ROLE_TO_ASSUME: ${{ secrets.AWS_ROLE_TO_ASSUME }} | |
| run: | | |
| set -eu | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | |
| tag="${{ inputs.version }}" | |
| else | |
| tag="${{ github.ref_name }}" | |
| fi | |
| version="${tag#v}" | |
| image="ghcr.io/${{ github.repository_owner }}/ftp-deployment-action" | |
| { | |
| echo "tag=${tag}" | |
| echo "version=${version}" | |
| echo "image=${image}" | |
| } >> "$GITHUB_OUTPUT" | |
| # ghcr.io is always published (GITHUB_TOKEN is automatic). | |
| tags="${image}:${version}"$'\n'"${image}:${tag}"$'\n'"${image}:latest" | |
| dockerhub_image="" | |
| ecr_image="" | |
| if [ -n "${DOCKERHUB_USERNAME}" ] && [ -n "${DOCKERHUB_TOKEN}" ]; then | |
| dockerhub_image="docker.io/airvzxf/ftp-deployment-action" | |
| tags="${tags}"$'\n'"${dockerhub_image}:${version}"$'\n'"${dockerhub_image}:${tag}"$'\n'"${dockerhub_image}:latest" | |
| { | |
| echo "dockerhub_image=${dockerhub_image}" | |
| echo "dockerhub_enabled=true" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "Docker Hub publishing ENABLED (${dockerhub_image})" | |
| else | |
| echo "::notice::Docker Hub secrets (DOCKERHUB_USERNAME, DOCKERHUB_TOKEN) not set; skipping docker.io publish" | |
| fi | |
| if [ -n "${AWS_ROLE_TO_ASSUME}" ]; then | |
| ecr_image="public.ecr.aws/airvzxf/ftp-deployment-action" | |
| tags="${tags}"$'\n'"${ecr_image}:${version}"$'\n'"${ecr_image}:${tag}"$'\n'"${ecr_image}:latest" | |
| { | |
| echo "ecr_image=${ecr_image}" | |
| echo "ecr_enabled=true" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "ECR Public publishing ENABLED (${ecr_image})" | |
| else | |
| echo "::notice::AWS_ROLE_TO_ASSUME secret not set; skipping public.ecr.aws publish" | |
| fi | |
| # Heredoc-style multi-line output for `all_tags`. Each tag | |
| # is written on its own line with `echo` so the EOF | |
| # delimiter lands on its own line — `printf '%s'` would | |
| # glue the last tag to the EOF marker and trip GitHub's | |
| # output parser with "Matching delimiter not found 'EOF'". | |
| # This per-line shape also guarantees no trailing newline | |
| # on the last tag, which would otherwise produce a spurious | |
| # empty tag in docker/build-push-action. | |
| { | |
| echo "all_tags<<EOF" | |
| echo "${image}:${version}" | |
| echo "${image}:${tag}" | |
| echo "${image}:latest" | |
| if [ -n "${dockerhub_image}" ]; then | |
| echo "${dockerhub_image}:${version}" | |
| echo "${dockerhub_image}:${tag}" | |
| echo "${dockerhub_image}:latest" | |
| fi | |
| if [ -n "${ecr_image}" ]; then | |
| echo "${ecr_image}:${version}" | |
| echo "${ecr_image}:${tag}" | |
| echo "${ecr_image}:latest" | |
| fi | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "Resolved tag=${tag}, version=${version}" | |
| echo "Tags to push:" | |
| printf '%s\n' "${tags}" | |
| - name: Checkout source at the release tag | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ steps.meta.outputs.tag }} | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 | |
| - name: Log in to ghcr.io | |
| uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| # v2.10.0+: Docker Hub is a SECONDARY publish target. The | |
| # conditional is gated on both DOCKERHUB_USERNAME and | |
| # DOCKERHUB_TOKEN being present; missing either is a no-op | |
| # (with a ::notice:: from the meta step) so forks and the | |
| # upstream maintainer can iterate without touching secrets | |
| # globally. | |
| - name: Log in to Docker Hub | |
| if: ${{ steps.meta.outputs.dockerhub_enabled == 'true' }} | |
| uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 | |
| with: | |
| registry: docker.io | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| # v2.10.0+: ECR Public is a SECONDARY publish target. OIDC | |
| # role assumption (no static AWS secrets) is configured in | |
| # two steps: configure-aws-credentials assumes the role and | |
| # exports AWS_ACCESS_KEY_ID/secret/session to subsequent | |
| # steps; amazon-ecr-login then uses those credentials to | |
| # call ecr-public:GetAuthorizationToken and to docker login | |
| # to public.ecr.aws. The IAM role trust policy must list | |
| # `token.actions.githubusercontent.com` as a federated | |
| # principal with a `sub` condition matching this repo (see | |
| # README section "Publishing targets" for the JSON). | |
| - name: Configure AWS credentials (OIDC) for ECR Public | |
| if: ${{ steps.meta.outputs.ecr_enabled == 'true' }} | |
| uses: aws-actions/configure-aws-credentials@v4 | |
| with: | |
| role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} | |
| # ECR Public control plane lives in us-east-1 regardless | |
| # of where the ECR Public registry "lives" (ECR Public | |
| # has no region concept; the public.ecr.aws endpoint is | |
| # global). | |
| aws-region: us-east-1 | |
| - name: Log in to ECR Public | |
| if: ${{ steps.meta.outputs.ecr_enabled == 'true' }} | |
| uses: aws-actions/amazon-ecr-login@v2 | |
| with: | |
| registry-type: public | |
| - name: Build and push image | |
| id: build | |
| uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 | |
| with: | |
| context: . | |
| file: ./Dockerfile | |
| push: true | |
| # Bake the resolved tag into /app/VERSION so init.sh's | |
| # deprecation warning can print the actual image version | |
| # (default in the Dockerfile is "dev" for local builds). | |
| build-args: VERSION=${{ steps.meta.outputs.tag }} | |
| # `all_tags` is a multi-line string emitted by the meta | |
| # step. The buildx builder receives every tag exactly | |
| # once and pushes each to the corresponding registry | |
| # (docker/build-push-action routes per-tag based on the | |
| # registry hostname). | |
| tags: ${{ steps.meta.outputs.all_tags }} | |
| labels: | | |
| org.opencontainers.image.title=ftp-deployment-action | |
| org.opencontainers.image.description=GitHub Action that copies files via FTP/FTPS using lftp | |
| org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} | |
| org.opencontainers.image.licenses=AGPL-3.0 | |
| org.opencontainers.image.version=${{ steps.meta.outputs.version }} | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| # v2.4.0+: smoke-test the just-pushed image before signing | |
| # and attaching the SBOM. The release pipeline previously | |
| # only exercised the image via CI (which runs init.sh | |
| # against a plain alpine:3.23.3, not the published image | |
| # with its pinned lftp and ca-certificates versions). The | |
| # v2.3.0 release was cut and pushed with a build that | |
| # could not resolve lftp=4.9.2-r9 against the new alpine | |
| # 3.24 base image; the failure surfaced only at the | |
| # cosign step, after a broken image was already in ghcr.io | |
| # and after the SBOM was generated for the broken image. | |
| # This step catches that class of failure earlier: it | |
| # pulls the just-pushed image, runs three cheap checks | |
| # (path-traversal validation, deprecation warning, the | |
| # /app/VERSION bake), and aborts the pipeline on any | |
| # failure. SBOM generation, cosign signing, and the SBOM | |
| # attestation are skipped on failure, so we never publish | |
| # or sign a broken image. | |
| # | |
| # v2.10.0+: smoke-test only the ghcr.io image. The three | |
| # registries receive the exact same image bytes from a | |
| # single `docker buildx build`, so a ghcr.io failure | |
| # implies the same failure on docker.io and public.ecr.aws. | |
| # Pulling the image three times would triple the pipeline | |
| # latency for zero added coverage. | |
| - name: Smoke test the just-pushed image | |
| env: | |
| IMAGE: ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }} | |
| run: | | |
| set -e | |
| tests/release-smoke.sh "${IMAGE}" | |
| - name: Generate SBOM (CycloneDX) | |
| uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 | |
| with: | |
| image: ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }} | |
| format: cyclonedx-json | |
| artifact-name: sbom.cyclonedx.json | |
| - name: Download SBOM artifact for attestation | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: sbom.cyclonedx.json | |
| path: . | |
| - name: Install cosign | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| # v2.10.0+: sign every published image. The digest is the | |
| # same across registries (single build produces one OCI | |
| # manifest, identical digests), so we sign | |
| # `<registry>/<ns>/image@digest` per enabled registry. The | |
| # digest is captured into an env var so secret masking | |
| # doesn't apply. | |
| - name: Sign image with cosign (keyless, OIDC) | |
| env: | |
| COSIGN_EXPERIMENTAL: '1' | |
| DIGEST: ${{ steps.build.outputs.digest }} | |
| run: | | |
| set -eu | |
| cosign sign --yes "${{ steps.meta.outputs.image }}@${DIGEST}" | |
| if [ "${{ steps.meta.outputs.dockerhub_enabled }}" = "true" ]; then | |
| cosign sign --yes "${{ steps.meta.outputs.dockerhub_image }}@${DIGEST}" | |
| fi | |
| if [ "${{ steps.meta.outputs.ecr_enabled }}" = "true" ]; then | |
| cosign sign --yes "${{ steps.meta.outputs.ecr_image }}@${DIGEST}" | |
| fi | |
| # v2.10.0+: attach the SBOM attestation to every published | |
| # image. `actions/attest` takes a single (subject-name, | |
| # subject-digest) pair per call, so we invoke it once per | |
| # enabled registry. The signature, attestation, and image | |
| # manifest together constitute the supply-chain bundle. | |
| - name: Attach SBOM attestation to ghcr.io image | |
| uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 | |
| with: | |
| subject-name: ${{ steps.meta.outputs.image }} | |
| subject-digest: ${{ steps.build.outputs.digest }} | |
| sbom-path: sbom.cyclonedx.json | |
| push-to-registry: true | |
| - name: Attach SBOM attestation to Docker Hub image | |
| if: ${{ steps.meta.outputs.dockerhub_enabled == 'true' }} | |
| uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 | |
| with: | |
| subject-name: ${{ steps.meta.outputs.dockerhub_image }} | |
| subject-digest: ${{ steps.build.outputs.digest }} | |
| sbom-path: sbom.cyclonedx.json | |
| push-to-registry: true | |
| - name: Attach SBOM attestation to ECR Public image | |
| if: ${{ steps.meta.outputs.ecr_enabled == 'true' }} | |
| uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 | |
| with: | |
| subject-name: ${{ steps.meta.outputs.ecr_image }} | |
| subject-digest: ${{ steps.build.outputs.digest }} | |
| sbom-path: sbom.cyclonedx.json | |
| push-to-registry: true | |
| # Extract the CHANGELOG.md section for this version so the | |
| # GitHub Release body mirrors the existing markdown. The | |
| # awk pattern ignores any `## [Unreleased]` block (defensive | |
| # against the duplicate that lived in the changelog once and | |
| # against future [Unreleased] entries) and stops at the next | |
| # `## [...]` heading or the compare-link footer for this | |
| # version. `body-empty=true` is exported when no section was | |
| # found, so the next step can fall back to | |
| # `generate_release_notes: true`. | |
| - name: Extract CHANGELOG notes for this version | |
| id: notes | |
| run: | | |
| version="${{ steps.meta.outputs.version }}" | |
| notes=$(awk -v v="${version}" ' | |
| BEGIN { | |
| in_section = 0 | |
| v_escaped = v | |
| gsub(/\./, "\\.", v_escaped) | |
| heading_re = "^## \\[" v_escaped "\\]( -.*)?$" | |
| next_heading_re = "^## \\[" | |
| compare_re = "^\\[[0-9]+\\.[0-9]+\\.[0-9]+\\]:" | |
| } | |
| $0 ~ heading_re { in_section = 1; next } | |
| in_section && $0 ~ next_heading_re { exit } | |
| in_section && $0 ~ compare_re { exit } | |
| in_section { print } | |
| ' CHANGELOG.md) | |
| if [ -z "${notes}" ]; then | |
| echo "::warning::no CHANGELOG section found for ${version}; falling back to generate_release_notes" | |
| echo "body-empty=true" >> "$GITHUB_OUTPUT" | |
| else | |
| body_file=$(mktemp) | |
| printf '%s\n' "${notes}" > "${body_file}" | |
| echo "body-file=${body_file}" >> "$GITHUB_OUTPUT" | |
| fi | |
| # Create a GitHub Release for the tag. Auto-publish: a tag | |
| # in this repo is the maintainer's already-made decision to | |
| # ship, so the release goes live the moment the image is | |
| # signed and attached (no manual click). If a release turns | |
| # out to be wrong, the recovery path is a hotfix tag + | |
| # `gh release edit <bad-tag> --prerelease` (or delete via | |
| # the API), not a draft kept in reserve. `make_latest: 'true'` | |
| # marks this release as Latest so notification feeds and | |
| # tooling that hits `/releases/latest` see the new tag. | |
| # If the previous step did not find a CHANGELOG section, | |
| # `generate_release_notes` builds the body from the tag | |
| # delta as a fallback. | |
| - name: Create GitHub Release | |
| uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 | |
| with: | |
| tag_name: ${{ steps.meta.outputs.tag }} | |
| name: ${{ steps.meta.outputs.tag }} | |
| make_latest: 'true' | |
| generate_release_notes: ${{ steps.notes.outputs.body-empty == 'true' }} | |
| body_path: ${{ steps.notes.outputs.body-file }} |