Skip to content

fix(release): use the actual ECR Public registry ID m2z1h0m9 (#92) #20

fix(release): use the actual ECR Public registry ID m2z1h0m9 (#92)

fix(release): use the actual ECR Public registry ID m2z1h0m9 (#92) #20

Workflow file for this run

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/m2z1h0m9/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 }}