diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d6d486f7..60d4349f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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" + } + + # 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 + - 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 }} @@ -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 { @@ -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 @@ -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') 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 ----------------- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 697176e2..2837eb77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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/ 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') + runs-on: ${{ inputs.runner_type }} + 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 @@ -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: @@ -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' || '') ) }} diff --git a/docs/build-workflow.md b/docs/build-workflow.md index deebe56c..1fbd8de5 100644 --- a/docs/build-workflow.md +++ b/docs/build-workflow.md @@ -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.