Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 119 additions & 16 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -471,11 +471,88 @@ jobs:
github_token=${{ secrets.MANAGE_TOKEN }}

# ----------------- Cosign Image Signing -----------------
# Lets `on_existing_tag: skip` retry just the signing step for an already-pushed
# tag (e.g. after a transient cosign/Rekor failure) without a full rebuild.
- name: Resolve digest for existing tag
if: inputs.enable_cosign_sign && steps.preflight.outputs.should_build == 'false'
id: existing-digest
env:
APP_NAME: ${{ matrix.app.name }}
VERSION: ${{ steps.version.outputs.version }}
ENABLE_DOCKERHUB: ${{ inputs.enable_dockerhub }}
ENABLE_GHCR: ${{ inputs.enable_ghcr }}
DOCKERHUB_ORG: ${{ inputs.dockerhub_org }}
GHCR_ORG: ${{ steps.image-names.outputs.ghcr_org }}
run: |
set -uo pipefail
TAG="${VERSION#v}"

# GitHub Actions parses every line of step output for workflow commands
# (::warning::, ::error::, etc.). Registry inspect output is not
# attacker-controlled here, but it could still contain newlines or a
# stray "::" that would corrupt or spoof an annotation, so it's
# percent-encoded into a single line before being embedded in one.
escape_for_warning() {
local s="$1"
s="${s//%/%25}"
s="${s//$'\r'/%0D}"
s="${s//$'\n'/%0A}"
printf '%s' "$s"
}

# Captures stdout and stderr separately (not merged) so any stderr
# chatter on an otherwise-successful call (deprecation notices,
# credential-helper output) can't corrupt the JSON fed to jq.
#
# Emits digest then "true"/"false" (lookup failed) on separate lines.
# An empty digest is ambiguous by itself: it means either "tag
# genuinely absent here" (fine, that registry is just skipped) or
# "the lookup itself errored" (ambiguous — the tag may well exist
# and just went unsigned). The caller needs that distinction to
# avoid silently reporting success when a registry couldn't be
# verified at all.
resolve_digest() {
local ref="$1"
local out err digest="" failed="false"
local err_file
err_file="$(mktemp)"
if out=$(docker buildx imagetools inspect "$ref" --format '{{json .Manifest}}' 2>"$err_file"); then
digest=$(echo "$out" | jq -r '.digest // empty')
[ -z "$digest" ] && echo "::warning::docker buildx imagetools inspect for ${ref} returned no digest — cosign will skip this registry." >&2
else
failed="true"
err=$(cat "$err_file")
echo "::warning::Failed to inspect ${ref} (registry lookup error, possibly transient) — cosign will skip signing for this registry: $(escape_for_warning "$err")" >&2
fi
rm -f "$err_file"
printf '%s\n%s\n' "$digest" "$failed"
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Each registry is inspected independently: a tag can be immutable-blocked
# in one registry but absent from another (e.g. a prior partial push), so
# reusing one registry's digest for the other's ref would sign a
# nonexistent (or wrong) reference.
if [ "$ENABLE_DOCKERHUB" == "true" ]; then
{ read -r DH_DIGEST; read -r DH_FAILED; } < <(resolve_digest "${DOCKERHUB_ORG}/${APP_NAME}:${TAG}")
echo "dockerhub_digest=$DH_DIGEST" >> "$GITHUB_OUTPUT"
echo "dockerhub_lookup_failed=$DH_FAILED" >> "$GITHUB_OUTPUT"
fi

if [ "$ENABLE_GHCR" == "true" ]; then
{ read -r GHCR_DIGEST_OUT; read -r GHCR_FAILED; } < <(resolve_digest "ghcr.io/${GHCR_ORG}/${APP_NAME}:${TAG}")
echo "ghcr_digest=$GHCR_DIGEST_OUT" >> "$GITHUB_OUTPUT"
echo "ghcr_lookup_failed=$GHCR_FAILED" >> "$GITHUB_OUTPUT"
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Build cosign image references
if: inputs.enable_cosign_sign && steps.preflight.outputs.should_build != 'false'
if: inputs.enable_cosign_sign
id: cosign-refs
env:
DIGEST: ${{ steps.build-push.outputs.digest }}
DOCKERHUB_DIGEST: ${{ steps.existing-digest.outputs.dockerhub_digest }}
GHCR_DIGEST: ${{ steps.existing-digest.outputs.ghcr_digest }}
DOCKERHUB_LOOKUP_FAILED: ${{ steps.existing-digest.outputs.dockerhub_lookup_failed }}
GHCR_LOOKUP_FAILED: ${{ steps.existing-digest.outputs.ghcr_lookup_failed }}
ENABLE_DOCKERHUB: ${{ inputs.enable_dockerhub }}
ENABLE_GHCR: ${{ inputs.enable_ghcr }}
DOCKERHUB_ORG: ${{ inputs.dockerhub_org }}
Expand All @@ -484,13 +561,21 @@ jobs:
run: |
REFS=""

if [ "$ENABLE_DOCKERHUB" == "true" ]; then
REFS="docker.io/${DOCKERHUB_ORG}/${APP_NAME}@${DIGEST}"
# A single build-push pushes the identical content to every enabled
# registry, so DIGEST (from build-push) applies to both. When the build
# was skipped (existing tag), fall back to each registry's own resolved
# digest instead, and only emit a ref when that registry's digest was
# actually found.
DOCKERHUB_FINAL="${DIGEST:-$DOCKERHUB_DIGEST}"
GHCR_FINAL="${DIGEST:-$GHCR_DIGEST}"

if [ "$ENABLE_DOCKERHUB" == "true" ] && [ -n "$DOCKERHUB_FINAL" ]; then
REFS="docker.io/${DOCKERHUB_ORG}/${APP_NAME}@${DOCKERHUB_FINAL}"
fi

if [ "$ENABLE_GHCR" == "true" ]; then
if [ "$ENABLE_GHCR" == "true" ] && [ -n "$GHCR_FINAL" ]; then
[ -n "$REFS" ] && REFS="${REFS}"$'\n'
REFS="${REFS}ghcr.io/${GHCR_ORG}/${APP_NAME}@${DIGEST}"
REFS="${REFS}ghcr.io/${GHCR_ORG}/${APP_NAME}@${GHCR_FINAL}"
fi

{
Expand All @@ -499,8 +584,18 @@ jobs:
echo "EOF"
} >> "$GITHUB_OUTPUT"

# A registry lookup error (as opposed to a confirmed-absent tag) means
# that registry's image was never actually verified for signing, even
# if the other enabled registry resolves fine and cosign succeeds for
# it. Surface that as its own signal so the run isn't reported as a
# clean success while one registry's image silently stayed unsigned.
HAS_LOOKUP_ERROR="false"
[ "$ENABLE_DOCKERHUB" == "true" ] && [ "$DOCKERHUB_LOOKUP_FAILED" == "true" ] && HAS_LOOKUP_ERROR="true"
[ "$ENABLE_GHCR" == "true" ] && [ "$GHCR_LOOKUP_FAILED" == "true" ] && HAS_LOOKUP_ERROR="true"
echo "has_lookup_error=$HAS_LOOKUP_ERROR" >> "$GITHUB_OUTPUT"

- name: Sign container images with cosign
if: inputs.enable_cosign_sign && steps.preflight.outputs.should_build != 'false'
if: inputs.enable_cosign_sign
id: cosign-sign
continue-on-error: ${{ inputs.continue_gitops_on_signing_failure || false }}
uses: LerianStudio/github-actions-shared-workflows/src/security/cosign-sign@v1
Expand All @@ -511,25 +606,33 @@ jobs:
max-delay: ${{ inputs.cosign_max_delay }}

- name: Report cosign signing status
if: inputs.enable_cosign_sign && steps.preflight.outputs.should_build != 'false' && steps.cosign-sign.outcome == 'failure'
if: >-
!cancelled() &&
inputs.enable_cosign_sign &&
(steps.cosign-sign.outcome == 'failure' || steps.cosign-refs.outputs.has_lookup_error == 'true')
Comment thread
coderabbitai[bot] marked this conversation as resolved.
env:
REFS: ${{ steps.cosign-refs.outputs.refs }}
DIGEST: ${{ steps.build-push.outputs.digest }}
APP_NAME: ${{ matrix.app.name }}
SIGNING_FAILED: ${{ steps.cosign-sign.outcome == 'failure' }}
HAS_LOOKUP_ERROR: ${{ steps.cosign-refs.outputs.has_lookup_error }}
run: |
echo "::warning::Cosign signing FAILED for ${APP_NAME} after all retries — image is unsigned in the registry."
echo "::warning::Cosign signing did not complete for every image of ${APP_NAME} — one or more images may still be unsigned."
{
echo "### ⚠️ Unsigned image — manual action required"
echo "### ⚠️ Signing did not complete for every image — manual action required"
echo ""
echo "- **App:** \`${APP_NAME}\`"
echo "- **Digest:** \`${DIGEST}\`"
echo "- **Refs:**"
echo '```'
echo "${REFS}"
echo '```'
if [ "$HAS_LOOKUP_ERROR" == "true" ]; then
echo "- **Note:** a registry lookup failed while resolving the existing tag's digest (see the ::warning:: above) — that registry's image was never verified or signed, even if signing succeeded for the other one."
fi
if [ "$SIGNING_FAILED" == "true" ]; then
echo "- **Requested refs (each already includes its own digest):**"
echo '```'
echo "${REFS}"
echo '```'
fi
echo ""
echo "GitOps artifact upload was allowed to continue (\`continue_gitops_on_signing_failure: true\`)."
echo "The image is immutable and present in the registry but **not signed**. Run \`cosign sign\` manually against the digest above before promoting to production."
echo "The image(s) above are immutable and already present in the registry, but signing did not complete for every one — some may already be signed, others may not be. Verify with \`cosign verify\` and run \`cosign sign\` manually against any ref that isn't before promoting to production."
} >> "$GITHUB_STEP_SUMMARY"

# ----------------- GitOps Artifacts -----------------
Expand Down
89 changes: 71 additions & 18 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -351,22 +351,13 @@ jobs:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

# ----------------- Backmerge -----------------
- name: Backmerge ${{ inputs.backmerge_source }} → ${{ inputs.backmerge_target }}
if: |
inputs.backmerge_enabled &&
steps.semantic.outputs.new_release_published == 'true' &&
github.ref_name == inputs.backmerge_source
uses: LerianStudio/github-actions-shared-workflows/src/config/backmerge-sync@v1
with:
github-token: ${{ steps.app-token.outputs.token }}
source-branch: ${{ inputs.backmerge_source }}
target-branch: ${{ inputs.backmerge_target }}
mode: ${{ inputs.backmerge_mode }}
dry-run: ${{ inputs.dry_run }}
commit-message: "chore(release): backmerge ${source} into ${target} [skip ci]"
pr-title: "chore(release): backmerge ${source} → ${target} (v${{ steps.semantic.outputs.new_release_version }})"
git-user-name: ${{ secrets.LERIAN_CI_CD_USER_NAME }}
git-user-email: ${{ secrets.LERIAN_CI_CD_USER_EMAIL }}
# The post-release backmerge (backmerge_source → backmerge_target) is NOT
# done here anymore. It now runs in the dedicated `backmerge` job below,
# which depends on generate_changelog so the changelog commit is already
# on backmerge_source (main) before it is merged into backmerge_target
# (develop). Running it inline here completes before generate_changelog,
# so the target branch never received the changelog. The pre-version
# prerelease sync above stays inline — it must happen before version calc.

# ----------------- Per-leg release publish marker -----------------
# Matrix job outputs are last-writer-wins, so we persist each leg's
Expand Down Expand Up @@ -496,6 +487,67 @@ jobs:
openai-model: ${{ inputs.openai_model }}
bot-ignore-list: ${{ inputs.changelog_bot_ignore_list }}

# ----------------- Backmerge (post-changelog) -----------------
# Runs the post-release backmerge (backmerge_source → backmerge_target) only
# AFTER generate_changelog has committed the CHANGELOG to backmerge_source
# (main). The backmerge-sync composite fetches origin/<source> fresh, so
# ordering this job after the changelog commit is what guarantees the
# changelog is carried into backmerge_target (develop). This used to be a
# step inside publish_release, which finishes before generate_changelog and
# therefore backmerged main WITHOUT the changelog.
backmerge:
name: Backmerge ${{ inputs.backmerge_source }} → ${{ inputs.backmerge_target }}
needs: [publish_release, publish_release_status, update_major_tag, generate_changelog]
if: >-
always() &&
inputs.backmerge_enabled &&
needs.publish_release_status.outputs.release_published == 'true' &&
github.ref_name == inputs.backmerge_source &&
(needs.update_major_tag.result == 'success' || needs.update_major_tag.result == 'skipped') &&
(needs.generate_changelog.result == 'success' || needs.generate_changelog.result == 'skipped')
Comment thread
coderabbitai[bot] marked this conversation as resolved.
runs-on: ${{ inputs.runner_type }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
environment:
name: create_release
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
id: app-token
with:
client-id: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_APP_ID }}
private-key: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_PRIVATE_KEY }}

- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}

- name: Import GPG key
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
with:
gpg_private_key: ${{ secrets.LERIAN_CI_CD_USER_GPG_KEY }}
passphrase: ${{ secrets.LERIAN_CI_CD_USER_GPG_KEY_PASSWORD }}
git_committer_name: ${{ secrets.LERIAN_CI_CD_USER_NAME }}
git_committer_email: ${{ secrets.LERIAN_CI_CD_USER_EMAIL }}
git_config_global: true
git_user_signingkey: true
git_commit_gpgsign: true

- name: Backmerge ${{ inputs.backmerge_source }} → ${{ inputs.backmerge_target }}
uses: LerianStudio/github-actions-shared-workflows/src/config/backmerge-sync@v1
with:
github-token: ${{ steps.app-token.outputs.token }}
source-branch: ${{ inputs.backmerge_source }}
target-branch: ${{ inputs.backmerge_target }}
mode: ${{ inputs.backmerge_mode }}
dry-run: ${{ inputs.dry_run }}
commit-message: "chore(release): backmerge ${source} into ${target} [skip ci]"
pr-title: "chore(release): backmerge ${source} → ${target} (v${{ needs.publish_release_status.outputs.release_version }})"
git-user-name: ${{ secrets.LERIAN_CI_CD_USER_NAME }}
git-user-email: ${{ secrets.LERIAN_CI_CD_USER_EMAIL }}

# ----------------- Major Tag Update -----------------
update_major_tag:
name: Update Major Tag
Expand Down Expand Up @@ -557,7 +609,7 @@ jobs:
# Slack notification
notify:
name: Notify
needs: [prepare, publish_release, publish_release_status, generate_changelog, update_major_tag, enforce_latest]
needs: [prepare, publish_release, publish_release_status, generate_changelog, backmerge, update_major_tag, enforce_latest]
if: always() && needs.prepare.outputs.has_changes == 'true'
uses: ./.github/workflows/slack-notify.yml
with:
Expand All @@ -566,9 +618,10 @@ jobs:
&& 'failure' || 'success' }}
workflow_name: "Release"
failed_jobs: >-
${{ format('{0}{1}{2}{3}',
${{ format('{0}{1}{2}{3}{4}',
(needs.publish_release.result == 'failure' && 'Publish Release' || ''),
(needs.generate_changelog.result == 'failure' && ' / Generate Changelog' || ''),
(needs.backmerge.result == 'failure' && ' / Backmerge' || ''),
(needs.update_major_tag.result == 'failure' && ' / Update Major Tag' || ''),
(needs.enforce_latest.result == 'failure' && ' / Enforce Latest' || '')
) }}
Expand Down
2 changes: 1 addition & 1 deletion docs/build-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ Uses `secrets: inherit` pattern. Required secrets:
Before building, the workflow checks whether the target image tag already exists in each enabled registry (via `docker manifest inspect`, reusing the registry logins). This avoids a full rebuild that would only fail at push time on registries with tag immutability enabled. Behaviour is controlled by `on_existing_tag`:

- **`fail`** (default): abort early with a clear error instead of rebuilding then failing at push.
- **`skip`**: skip the build/push but still emit the GitOps tag artifacts (from the version), so a re-run remains idempotent for the downstream GitOps update.
- **`skip`**: skip the build/push but still emit the GitOps tag artifacts (from the version), so a re-run remains idempotent for the downstream GitOps update. Cosign signing (if enabled) also retries in this mode: the digest is resolved from the existing registry tag via `docker buildx imagetools inspect` instead of the build/push step, so a transient signing failure on an already-pushed tag can be recovered with a plain re-run instead of cutting a new release.
- **`warn`**: emit a warning and build anyway (push may still fail on immutable registries).

A non-existent tag (or a check that errors out, e.g. transient registry issues) is treated as "not present" so the check never blocks a legitimate build.
Expand Down
Loading