Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
94 changes: 81 additions & 13 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -471,11 +471,73 @@ 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.
resolve_digest() {
local ref="$1"
local out err digest=""
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
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' "$digest"
}
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
echo "dockerhub_digest=$(resolve_digest "${DOCKERHUB_ORG}/${APP_NAME}:${TAG}")" >> "$GITHUB_OUTPUT"
fi

if [ "$ENABLE_GHCR" == "true" ]; then
echo "ghcr_digest=$(resolve_digest "ghcr.io/${GHCR_ORG}/${APP_NAME}:${TAG}")" >> "$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 }}
ENABLE_DOCKERHUB: ${{ inputs.enable_dockerhub }}
ENABLE_GHCR: ${{ inputs.enable_ghcr }}
DOCKERHUB_ORG: ${{ inputs.dockerhub_org }}
Expand All @@ -484,13 +546,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 @@ -500,7 +570,7 @@ jobs:
} >> "$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 +581,23 @@ 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: inputs.enable_cosign_sign && steps.cosign-sign.outcome == 'failure'
env:
REFS: ${{ steps.cosign-refs.outputs.refs }}
DIGEST: ${{ steps.build-push.outputs.digest }}
APP_NAME: ${{ matrix.app.name }}
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 ref for ${APP_NAME} — one or more of the refs below may still be unsigned."
{
echo "### ⚠️ Unsigned image — manual action required"
echo "### ⚠️ Signing did not complete for every ref — manual action required"
echo ""
echo "- **App:** \`${APP_NAME}\`"
echo "- **Digest:** \`${DIGEST}\`"
echo "- **Refs:**"
echo "- **Requested refs (each already includes its own digest):**"
echo '```'
echo "${REFS}"
echo '```'
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 failed after all retries for at least one ref — some refs above 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