From 29db751fa40b6c98d62e050a2adaa02ee05093b2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:09:39 +0000 Subject: [PATCH 1/3] ci: align workflow structure with hriv reference implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite all CI/CD workflows to match the authoritative hriv monorepo pattern, adapted for a single-component repo. Key changes: ci.yaml: - Remove tag and workflow_dispatch triggers; fire on push/PR to main only - Remove monolithic 'versions' job; compute version inline from git tags + conventional commit analysis (same algorithm as hriv) - Replace docker/metadata-action with explicit sha- + -rc.. tag format matching hriv's image tag contract - Split Trivy scanning: table-to-log on PRs, SARIF-to-Security on main - Load (don't push) PR builds for local Trivy scanning - Disable provenance/SBOM attestations to keep exactly 2 image artifacts - Add per-component change detection (Dockerfile, Go source) - Adopt least-privilege permissions (workflow-level contents:read, job-level elevations) - Remove inline helm-publish job (moved to dedicated workflow) release-please.yaml: - Switch from prerelease mode to standard manifest mode - Add stale release-as guard (prevents stuck version overrides) - Dispatch helm-publish.yaml + release-retag.yaml on release (same GITHUB_TOKEN dispatch pattern as hriv) - Adopt least-privilege permissions with job-level elevations - Remove graduated-release promotion (not needed in standard mode) New workflows: - helm-publish.yaml: release-only Helm chart OCI publishing - release-retag.yaml: sha→semver+latest image retagging with Cosign - pr-title-lint.yaml: conventional-commit PR title enforcement with sticky failure comments release-please-config.json: - Remove versioning/prerelease/prerelease-type fields Co-Authored-By: kyle_hunter@bcit.ca --- .github/workflows/ci.yaml | 457 +++++++++++--------------- .github/workflows/helm-publish.yaml | 73 ++++ .github/workflows/pr-title-lint.yaml | 91 +++++ .github/workflows/release-please.yaml | 182 +++++----- .github/workflows/release-retag.yaml | 146 ++++++++ release-please-config.json | 3 - 6 files changed, 585 insertions(+), 367 deletions(-) create mode 100644 .github/workflows/helm-publish.yaml create mode 100644 .github/workflows/pr-title-lint.yaml create mode 100644 .github/workflows/release-retag.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0e1e3e3..d516cf4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,38 +1,37 @@ -name: CI – test, build & publish +name: "[CI] Test, build & publish" +# Readable run titles in the Actions UI: PR runs show the PR number and +# title; main pushes show the head commit subject. run-name: >- ${{ github.event_name == 'pull_request' && format('PR #{0} — {1}', github.event.pull_request.number, github.event.pull_request.title) - || startsWith(github.ref, 'refs/tags/') - && format('Release {0}', github.ref_name) || format('Head build — {0}', github.event.head_commit.message) }} on: push: branches: [main] - tags: ["v*"] + # Scope PR runs to PRs targeting `main` only; bare `pull_request:` fires + # for PRs against any base branch, which isn't what this workflow is + # meant to gate. pull_request: branches: [main] - # release-please.yaml re-dispatches this workflow on freshly-created - # tag refs, because `push: tags:` is not fired for tags pushed by - # GITHUB_TOKEN. When dispatched against a tag ref, the `versions` job - # falls into the tag-ref branch and behaves identically to a push - # event on that tag. - workflow_dispatch: env: REGISTRY: ghcr.io FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true +# Least-privilege default: only read access at the workflow level. +# Jobs that need more elevate locally. permissions: contents: read - packages: write - id-token: write # Cosign keyless (Sigstore OIDC) - security-events: write # Trivy SARIF upload to GitHub Security tab - actions: read # Required by codeql-action/upload-sarif on private repos jobs: # ── Quality gates ───────────────────────────────────────── + # + # Tests and vet run on every push + PR. helm-lint runs + # independently (its failure breaks the workflow status but + # does not block the image build). + go-test: runs-on: ubuntu-latest steps: @@ -74,294 +73,228 @@ jobs: echo "::endgroup::" done - # ── Compute publish plan (Vault/Rancher-style) ─────────────── + # ── Container image ────────────────────────────────────── # - # Tag model (strict-D — matches hashicorp/vault & rancher/rancher): - # v-rcN → "pre-release" git tag (release-please in prerelease - # mode). Published as the immutable OCI tag X.Y.Z-rcN - # only; NO moving pointer follows rcs. Consumers opt - # into an rc by pinning its version tag. - # v → "full release" git tag (graduated from an rc via - # `Release-As:` footer). Published as the immutable - # X.Y.Z plus, iff this is the highest full-release - # SemVer, the moving `stable` and `latest` OCI - # pointers. + # Requires `go-test` to pass. `!cancelled()` prevents a + # sibling gate failure from blocking this job entirely — the + # quality-gate check in the first step skips early when + # `go-test` itself failed. # - # Non-tag pushes to main publish the moving `head` OCI pointer — the - # continuous-integration artifact (mirrors rancher/rancher:head) — plus - # an immutable 0.0.0-head. version so flux-fleet consumers - # tracking `head` resolve to a concrete chart/image digest. - versions: + # `helm-lint` is intentionally NOT in `needs` — it runs as an + # independent parallel job whose failure surfaces via branch + # protection (required status check) rather than delaying or + # blocking image builds. + build: runs-on: ubuntu-latest - outputs: - mode: ${{ steps.compute.outputs.mode }} - version: ${{ steps.compute.outputs.version }} - is_prerelease: ${{ steps.compute.outputs.is_prerelease }} - is_highest_stable: ${{ steps.compute.outputs.is_highest_stable }} + needs: [go-test] + if: "!cancelled()" + permissions: + contents: read + packages: write + id-token: write # Cosign keyless (Sigstore OIDC) + security-events: write # Trivy SARIF upload to GitHub Security tab steps: + - name: Check quality gate + shell: bash + run: | + result="${{ needs.go-test.result }}" + echo "gate=go-test result=${result}" + if [[ "${result}" != "success" ]]; then + echo "::error::Quality gate 'go-test' did not succeed (result=${result}); skipping build." + exit 1 + fi + - uses: actions/checkout@v6 with: - fetch-depth: 0 # need full history + tags for highest-stable check + fetch-depth: 0 - - name: Compute publish plan - id: compute + - name: Detect changes + id: changes shell: bash run: | set -euo pipefail - SHORT_SHA="${GITHUB_SHA::7}" - TAG_REF="${GITHUB_REF#refs/tags/}" - MODE="skip" - VERSION="" - IS_PRERELEASE=false - IS_HIGHEST_STABLE=false - - if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then - MODE="release" - VERSION="${TAG_REF#v}" - if [[ "${VERSION}" == *-* ]]; then - IS_PRERELEASE=true - else - ALL_STABLE=$(git tag -l "v[0-9]*.[0-9]*.[0-9]*" \ - | grep -v -- '-' \ - | sed "s/^v//" \ - || true) - HIGHEST_STABLE=$(printf '%s\n' "${ALL_STABLE}" | sort -V | tail -n1) - if [[ "${VERSION}" == "${HIGHEST_STABLE}" ]]; then - IS_HIGHEST_STABLE=true - fi - fi - elif [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then - # Anchor the head build's version to the next planned - # release so operators can read image / chart tags and - # immediately see which target SemVer the code is heading - # toward. The base is read from .release-please-manifest.json - # (release-please's single source of truth for the next - # version) and stripped of any prerelease suffix. This - # yields tags that SemVer-sort: head < rc < full. - # - # Rule: - # * manifest holds a prerelease (e.g. `0.3.0-rc`) → - # strip the `-rc` suffix and target the upcoming minor - # (`0.3.0-head.SHA` < `0.3.0-rc`). - # * manifest holds a clean stable (e.g. `0.2.0`) → - # bump patch so head sorts strictly ABOVE the last - # stable release (`0.2.1-head.SHA` > `0.2.0`). This - # covers the brief window after graduation but before - # release-please opens the next rc PR, and is agnostic - # to the tag naming scheme (handles both the new - # `vX.Y.Z` format and the legacy `-vX.Y.Z` - # format without needing a git-tag existence check). - MODE="head" - BASE=$(jq -r '.["."]' .release-please-manifest.json) - [[ -n "${BASE}" && "${BASE}" != "null" ]] || { - echo "::error::Missing '.' version in .release-please-manifest.json" >&2 - exit 1 - } - BASE_STABLE="${BASE%-*}" - if [[ "${BASE}" == "${BASE_STABLE}" ]]; then - IFS=. read -r MAJ MIN PAT <<<"${BASE_STABLE}" - BASE_STABLE="${MAJ}.${MIN}.$((PAT + 1))" - fi - VERSION="${BASE_STABLE}-head.${SHORT_SHA}" + + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" else - MODE="pr" - VERSION="0.0.0-pr.${SHORT_SHA}" + BASE="${{ github.event.before }}" + HEAD="${{ github.sha }}" fi - echo "mode=${MODE}" >> "$GITHUB_OUTPUT" - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "is_prerelease=${IS_PRERELEASE}" >> "$GITHUB_OUTPUT" - echo "is_highest_stable=${IS_HIGHEST_STABLE}" >> "$GITHUB_OUTPUT" - echo " mode=${MODE} version=${VERSION} prerelease=${IS_PRERELEASE} highest_stable=${IS_HIGHEST_STABLE}" + # `github.event.before` is the all-zeroes SHA on first pushes + # to a new ref (and occasionally missing from the local clone + # on force-pushes / history edits). Fall back to HEAD~1 when + # available so `git diff` doesn't abort the job; if HEAD has + # no parent (initial commit), treat everything as changed. + ZERO_SHA="0000000000000000000000000000000000000000" + if [[ -z "${BASE}" || "${BASE}" == "${ZERO_SHA}" ]] \ + || ! git cat-file -e "${BASE}^{commit}" 2>/dev/null; then + if git rev-parse --verify "${HEAD}^" >/dev/null 2>&1; then + BASE="${HEAD}^" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi - # ── Container image ─────────────────────────────────────── - image: - runs-on: ubuntu-latest - needs: [go-test, versions] - steps: - - uses: actions/checkout@v6 + if git diff --quiet "${BASE}" "${HEAD}" -- Dockerfile go.mod go.sum main.go internal/; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi - uses: docker/setup-buildx-action@v4 + if: steps.changes.outputs.changed == 'true' - # Publish gate: only `head` (main push) and `release` (tag push / - # release-please tag re-dispatch) modes may push/sign/scan. PR - # builds (mode=pr) and any manual workflow_dispatch on a non-tag - # ref (also mode=pr) must stay local — no synthetic `0.0.0-pr.*` - # artifacts in the registry. Steps below all key off this flag so - # the image job still runs as a build-only sanity check on PRs. - uses: docker/login-action@v4 - if: needs.versions.outputs.mode == 'head' || needs.versions.outputs.mode == 'release' + if: steps.changes.outputs.changed == 'true' && github.event_name != 'pull_request' with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # Publish tags by mode (matches hashicorp/vault, rancher/rancher): - # mode=release (tag push): - # always → - # if full release + highest stable semver → stable, latest - # mode=head (main branch push): - # always → (= 0.0.0-head.) + moving `head` - # mode=pr (pull request): - # always → (= 0.0.0-pr.) (not pushed to registry) - - uses: docker/metadata-action@v6 - id: meta - with: - images: ${{ env.REGISTRY }}/${{ github.repository }}/haproxy-operator - tags: | - type=raw,value=${{ needs.versions.outputs.version }},enable=${{ needs.versions.outputs.mode != 'skip' }} - type=raw,value=head,enable=${{ needs.versions.outputs.mode == 'head' }} - type=raw,value=stable,enable=${{ needs.versions.outputs.mode == 'release' && needs.versions.outputs.is_prerelease == 'false' && needs.versions.outputs.is_highest_stable == 'true' }} - type=raw,value=latest,enable=${{ needs.versions.outputs.mode == 'release' && needs.versions.outputs.is_prerelease == 'false' && needs.versions.outputs.is_highest_stable == 'true' }} - flavor: | - latest=false - - - uses: docker/build-push-action@v7 - id: build + - name: Compute next version + id: version + if: steps.changes.outputs.changed == 'true' + shell: bash + run: | + set -euo pipefail + + TAG=$(git describe --tags \ + --match "v*" \ + --exclude "v*-*" \ + --abbrev=0 2>/dev/null || true) + if [[ -z "$TAG" ]]; then + VERSION="0.0.0" + RANGE="HEAD" + else + VERSION="${TAG#v}" + RANGE="${TAG}..HEAD" + fi + + if ! [[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "::error::Refusing to bump from non-semver base version '${VERSION}' (tag '${TAG}')" + exit 1 + fi + MAJOR="${BASH_REMATCH[1]}" + MINOR="${BASH_REMATCH[2]}" + PATCH="${BASH_REMATCH[3]}" + + SUBJECTS=$(git log "${RANGE}" --pretty=format:"%s" -- Dockerfile go.mod go.sum main.go internal/) + BODIES=$(git log "${RANGE}" --pretty=format:"%b" -- Dockerfile go.mod go.sum main.go internal/) + + if echo "$SUBJECTS" | grep -qE "^[a-zA-Z]+(\([^)]+\))?!:" \ + || echo "$BODIES" | grep -qE "^BREAKING[ -]CHANGE:"; then + ((MAJOR+=1)); MINOR=0; PATCH=0 + elif echo "$SUBJECTS" | grep -qE "^feat(\(.+\))?:"; then + ((MINOR+=1)); PATCH=0 + else + ((PATCH+=1)) + fi + + NEXT="${MAJOR}.${MINOR}.${PATCH}" + SHORT_SHA=$(git rev-parse --short HEAD) + # Numerical timestamp suffix used by Flux ImagePolicy + # (extract=numerical, pattern captures $ts) to sort + # images by build recency. Fixed-width 14-char format + # also sorts lexically, so SemVer prerelease comparison + # of the same `-rc.` identifier across different timestamps + # produces the right order. + TS=$(date -u +"%Y%m%d%H%M%S") + + echo "next=${NEXT}" >> "$GITHUB_OUTPUT" + echo "short=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + echo "ts=${TS}" >> "$GITHUB_OUTPUT" + + - name: Build and push + id: build-push + if: steps.changes.outputs.changed == 'true' + uses: docker/build-push-action@v7 with: context: . - push: ${{ needs.versions.outputs.mode == 'head' || needs.versions.outputs.mode == 'release' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - sbom: ${{ needs.versions.outputs.mode == 'head' || needs.versions.outputs.mode == 'release' }} - provenance: ${{ needs.versions.outputs.mode == 'head' || needs.versions.outputs.mode == 'release' }} + push: ${{ github.event_name != 'pull_request' }} + # PR builds don't push to the registry, but load locally so + # the Trivy step can scan the image by tag. + load: ${{ github.event_name == 'pull_request' }} + # Skip SLSA provenance + SBOM attestations so each push + # produces a single image manifest per tag. Without this, + # buildx creates an extra unnamed attestation manifest + # alongside every tag, doubling the artifacts visible under + # ghcr's package view. + provenance: false + sbom: false cache-from: type=gha cache-to: type=gha,mode=max + # Commit identity travels with the image as OCI manifest + # labels rather than baked build-time `ARG`s. Labels survive + # `release-retag.yaml`'s digest-promotion so + # `docker buildx imagetools inspect` always recovers the + # exact commit an image was built from. + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ steps.version.outputs.next }}-rc.${{ steps.version.outputs.short }} + tags: | + ${{ env.REGISTRY }}/${{ github.repository }}/haproxy-operator:sha-${{ github.sha }} + ${{ env.REGISTRY }}/${{ github.repository }}/haproxy-operator:${{ steps.version.outputs.next }}-rc.${{ steps.version.outputs.ts }}.${{ steps.version.outputs.short }} # ── Sign with Cosign (keyless / Sigstore) ────────────── - uses: sigstore/cosign-installer@v3 - if: needs.versions.outputs.mode == 'head' || needs.versions.outputs.mode == 'release' + if: steps.changes.outputs.changed == 'true' && github.event_name != 'pull_request' - name: Sign image - if: needs.versions.outputs.mode == 'head' || needs.versions.outputs.mode == 'release' + if: steps.changes.outputs.changed == 'true' && github.event_name != 'pull_request' run: | cosign sign --yes \ - ${{ env.REGISTRY }}/${{ github.repository }}/haproxy-operator@${{ steps.build.outputs.digest }} + ${{ env.REGISTRY }}/${{ github.repository }}/haproxy-operator@${{ steps.build-push.outputs.digest }} + + # ── Trivy vulnerability scan ─────────────────────────── + # + # Runs on every build so regressions surface on the PR that + # introduces them rather than after merge to main. + # + # * main pushes: scan by digest, upload SARIF to Security tab. + # `ignore-unfixed` disabled for full inventory/audit. + # * PRs: scan locally loaded image by tag. `ignore-unfixed: true` + # trims noise to CVEs with an upstream fix. Advisory today + # (exit-code 0) — flip to 1 once base-image CVEs are resolved. + - name: Resolve image reference for Trivy + id: scan-ref + if: steps.changes.outputs.changed == 'true' + shell: bash + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "ref=${{ env.REGISTRY }}/${{ github.repository }}/haproxy-operator:sha-${{ github.sha }}" >> "$GITHUB_OUTPUT" + else + echo "ref=${{ env.REGISTRY }}/${{ github.repository }}/haproxy-operator@${{ steps.build-push.outputs.digest }}" >> "$GITHUB_OUTPUT" + fi - # ── Trivy vulnerability scan → GitHub Security tab ───── - - name: Scan image for vulnerabilities - if: needs.versions.outputs.mode == 'head' || needs.versions.outputs.mode == 'release' + - name: Scan image (PR — table to log) + if: steps.changes.outputs.changed == 'true' && github.event_name == 'pull_request' uses: aquasecurity/trivy-action@v0.35.0 with: - image-ref: ${{ env.REGISTRY }}/${{ github.repository }}/haproxy-operator@${{ steps.build.outputs.digest }} + image-ref: ${{ steps.scan-ref.outputs.ref }} + format: table + severity: CRITICAL,HIGH + ignore-unfixed: true + # TODO: flip to '1' once base-image cleanup lands. + exit-code: "0" + + - name: Scan image (main — SARIF to Security tab) + if: steps.changes.outputs.changed == 'true' && github.event_name != 'pull_request' + uses: aquasecurity/trivy-action@v0.35.0 + with: + image-ref: ${{ steps.scan-ref.outputs.ref }} format: sarif - output: trivy.sarif + output: trivy-haproxy-operator.sarif severity: CRITICAL,HIGH + ignore-unfixed: false + exit-code: "0" - name: Upload scan results to GitHub Security - id: upload-sarif - if: needs.versions.outputs.mode == 'head' || needs.versions.outputs.mode == 'release' + if: steps.changes.outputs.changed == 'true' && github.event_name != 'pull_request' uses: github/codeql-action/upload-sarif@v4 - continue-on-error: true # Never block chart publish on Security-tab upload with: - sarif_file: trivy.sarif - category: trivy - - - name: Surface SARIF upload failure - if: steps.upload-sarif.outcome == 'failure' - run: | - echo "::warning title=Trivy SARIF upload failed::Scan results did NOT reach the GitHub Security tab for this run. Inspect the 'Upload scan results to GitHub Security' step above. The image was still built, pushed, signed, and scanned locally." - - # ── Helm chart → OCI registry ──────────────────────────── - # - # Charts are published on: - # * tag push (mode=release) — versioned artifact + conditional - # stable/latest moving tags (when highest full-release SemVer). - # * main push (mode=head) — 0.0.0-head. versioned artifact + - # the moving `head` OCI tag. Lets flux-fleet's latest overlay - # track main HEAD continuously. - # - # PR builds do not publish. - helm-publish: - runs-on: ubuntu-latest - needs: [helm-lint, image, versions] - # Publish on real main-branch pushes (mode=head) and real tag pushes - # / release-please tag dispatches (mode=release). PR builds and - # manual workflow_dispatch on non-tag refs both produce mode=pr - # artifacts which must never reach the registry. - if: >- - github.event_name != 'pull_request' - && (github.event_name != 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')) - steps: - - uses: actions/checkout@v6 - - - uses: azure/setup-helm@v5 - - - uses: sigstore/cosign-installer@v3 - - - uses: imjasonh/setup-crane@v0.5 - - - uses: docker/login-action@v4 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Log in to GHCR (Helm OCI) - run: | - echo "${{ secrets.GITHUB_TOKEN }}" | \ - helm registry login ${{ env.REGISTRY }} \ - -u ${{ github.actor }} --password-stdin - - - name: Package, push, tag & sign charts - shell: bash - env: - MODE: ${{ needs.versions.outputs.mode }} - VERSION: ${{ needs.versions.outputs.version }} - IS_PRERELEASE: ${{ needs.versions.outputs.is_prerelease }} - IS_HIGHEST_STABLE: ${{ needs.versions.outputs.is_highest_stable }} - run: | - set -euo pipefail - - # Defense-in-depth: the job-level `if:` already excludes - # PR / non-tag workflow_dispatch events, but guard against - # future event types or config drift that could let MODE=pr - # slip past and accidentally publish synthetic artifacts. - case "${MODE}" in - head|release) ;; - *) echo "::error::helm-publish invoked with unexpected mode: ${MODE}" >&2; exit 1 ;; - esac - - OCI_BASE="oci://${{ env.REGISTRY }}/${{ github.repository }}/charts" - IMAGE_BASE="${{ env.REGISTRY }}/${{ github.repository }}/charts" - - for dir in charts/*/; do - chart=$(basename "$dir") - echo "::group::${chart}" - - # ── Unified chart + app version ────────────────────── - # Chart version and app version are the same string — the - # one computed by the `versions` job — so the chart OCI - # tag, the image tag, and Chart.AppVersion all match the - # release-please lineage. Both Chart.yaml lines carry the - # `x-release-please-version` marker; release-please bumps - # them in lockstep on every rc / full release, and head - # mode stamps the manifest-derived prerelease form computed - # by the `versions` job. - sed -i "s/^version:.*/version: ${VERSION} # x-release-please-version/" "${dir}Chart.yaml" - sed -i "s/^appVersion:.*/appVersion: \"${VERSION}\" # x-release-please-version/" "${dir}Chart.yaml" - - helm package "$dir" -d /tmp/charts - PUSH_OUT=$(helm push "/tmp/charts/${chart}-${VERSION}.tgz" "${OCI_BASE}" 2>&1) - echo "${PUSH_OUT}" - - DIGEST=$(printf '%s\n' "${PUSH_OUT}" | awk '/^Digest:/{print $2}') - [[ -n "${DIGEST}" ]] || { echo "Failed to capture digest from helm push" >&2; exit 1; } - - # Moving-tag rules (strict-D — matches hashicorp/vault & - # rancher/rancher): - # mode=head → add `head` - # mode=release rc → no moving tag (immutable only) - # mode=release stable → add `stable` + `latest` iff highest - # full-release SemVer - if [[ "${MODE}" == "head" ]]; then - crane tag "${IMAGE_BASE}/${chart}:${VERSION}" head - elif [[ "${MODE}" == "release" && "${IS_PRERELEASE}" != "true" && "${IS_HIGHEST_STABLE}" == "true" ]]; then - crane tag "${IMAGE_BASE}/${chart}:${VERSION}" stable - crane tag "${IMAGE_BASE}/${chart}:${VERSION}" latest - fi - - cosign sign --yes "${IMAGE_BASE}/${chart}@${DIGEST}" - - echo "::endgroup::" - done + sarif_file: trivy-haproxy-operator.sarif + category: trivy-haproxy-operator diff --git a/.github/workflows/helm-publish.yaml b/.github/workflows/helm-publish.yaml new file mode 100644 index 0000000..849e15b --- /dev/null +++ b/.github/workflows/helm-publish.yaml @@ -0,0 +1,73 @@ +name: helm-publish + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag_name: + description: "Release tag to package and publish (e.g. v1.2.3)" + required: true + type: string + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Parse release tag + id: parse + shell: bash + env: + TAG: ${{ inputs.tag_name || github.event.release.tag_name }} + run: | + if [[ "$TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "version=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT" + echo "publish=true" >> "$GITHUB_OUTPUT" + else + echo "publish=false" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@v6 + if: steps.parse.outputs.publish == 'true' + + - uses: azure/setup-helm@v5 + if: steps.parse.outputs.publish == 'true' + + - name: Helm login (OCI) + if: steps.parse.outputs.publish == 'true' + shell: bash + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | \ + helm registry login "${{ env.REGISTRY }}" \ + -u "${{ github.actor }}" \ + --password-stdin + + - name: Package and push chart + if: steps.parse.outputs.publish == 'true' + shell: bash + run: | + set -euo pipefail + + VERSION="${{ steps.parse.outputs.version }}" + CHART_DIR="charts/haproxy-operator" + DEST_DIR="/tmp/charts" + + mkdir -p "${DEST_DIR}" + + helm dependency update "${CHART_DIR}" || true + + helm package "${CHART_DIR}" \ + --version "${VERSION}" \ + --app-version "${VERSION}" \ + -d "${DEST_DIR}" + + helm push \ + "${DEST_DIR}/haproxy-operator-${VERSION}.tgz" \ + "oci://${{ env.REGISTRY }}/${{ github.repository }}/charts" diff --git a/.github/workflows/pr-title-lint.yaml b/.github/workflows/pr-title-lint.yaml new file mode 100644 index 0000000..0086226 --- /dev/null +++ b/.github/workflows/pr-title-lint.yaml @@ -0,0 +1,91 @@ +name: pr-title-lint + +# Gate non-conventional PR titles at the merge boundary so unparseable +# squash-merge commits can't land on main and silently starve +# release-please of the feat:/fix:/BREAKING signals it uses to open +# release PRs. + +on: + pull_request_target: + types: [opened, edited, reopened, synchronize] + branches: [main] + +permissions: + contents: read + # Elevated from ``read`` to ``write`` so the failure-handler step + # below can post / update a sticky comment on the PR explaining the + # failing reason to the author. ``pull_request_target`` runs in the + # base-ref context so this write token cannot be exfiltrated by an + # adversarial PR branch (the workflow file used is ``main``'s copy, + # not the PR branch's). + pull-requests: write + +jobs: + lint: + runs-on: ubuntu-latest + # Skip for bot-authored PRs. release-please's own titles are always + # well-formed ``chore(main): release `` so there's nothing for + # the lint to guard anyway. + if: github.event.pull_request.user.type != 'Bot' + steps: + - id: lint + uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # Mirror release-please's default conventional-commits parser. + # Keep this list in sync with any custom `changelog-sections` + # override in release-please-config.json. + types: | + feat + fix + docs + style + refactor + perf + test + build + ci + chore + revert + requireScope: false + subjectPattern: ^(?![A-Z]).+$ + subjectPatternError: | + The subject "{subject}" in PR title "{title}" must start with a + lowercase letter (imperative mood, no trailing period). See + CONTRIBUTING.md for examples. + + # When the lint fails, post (or update) a sticky comment on the + # PR with a tl;dr of the rules. + - name: Post failure explainer comment + if: failure() && steps.lint.outcome == 'failure' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: pr-title-lint + message: | + ## PR title does not match Conventional Commits + + **Why this matters (tl;dr)** — release-please reads PR titles on `main` to decide whether to cut a release, what version bump to apply, and how to group CHANGELOG entries. A title that doesn't parse is silently dropped; no release PR opens, no error, workflow still exits 0. + + **Required structure** + + ``` + [optional scope][!]: + ``` + + - `` — `feat` / `fix` / `docs` / `style` / `refactor` / `perf` / `test` / `build` / `ci` / `chore` / `revert` + - `` — lowercase first letter, imperative mood, no trailing period + - add `!` (or `BREAKING CHANGE:` in the body) for breaking changes + + **Good** — `feat: add structured config mode`, `fix: prevent reconcile loop on annotation change`, `ci: tighten workflow permissions` + + **Bad** — `Add feature` (no type), `feat: Add feature` (uppercase subject) + + Edit the PR title in the GitHub UI — the check re-runs automatically within seconds, no push needed. + + - name: Clear failure explainer on success + if: success() && steps.lint.outcome == 'success' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: pr-title-lint + delete: true diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml index 879d943..510a2d4 100644 --- a/.github/workflows/release-please.yaml +++ b/.github/workflows/release-please.yaml @@ -1,125 +1,103 @@ -name: Release Please +name: release-please on: push: branches: [main] +# Least-privilege default: only `checkout` needs read access. +# Each job elevates permissions to just what that job requires. permissions: - contents: write - pull-requests: write - # `actions: write` is required for the dispatch-publish job below to - # call `gh workflow run` on ci.yaml. GitHub explicitly forbids workflow - # runs created by tag pushes that are themselves made by GITHUB_TOKEN - # — so the `on: push: tags:` trigger in ci.yaml never fires for - # release-please tags. `workflow_dispatch`, however, IS allowed from - # GITHUB_TOKEN, so we re-dispatch ci.yaml explicitly against each - # just-created tag ref. - actions: write + contents: read jobs: - release-please: + release: runs-on: ubuntu-latest + # release-please creates/updates release PRs, tags, GitHub releases + # and comments on associated issues. + permissions: + contents: write + issues: write + pull-requests: write outputs: releases_created: ${{ steps.rp.outputs.releases_created }} - tag_name: ${{ steps.rp.outputs.tag_name }} + paths_released: ${{ steps.rp.outputs.paths_released }} + outputs: ${{ toJSON(steps.rp.outputs) }} steps: + # Manifest mode is inferred from the presence of + # `release-please-config.json` + `.release-please-manifest.json`; + # the top-level `release-type` input only accepts the leaf values + # and must be omitted here so each component's `release-type` from + # the config drives the per-package strategy. - uses: googleapis/release-please-action@v4 id: rp with: - config-file: release-please-config.json - manifest-file: .release-please-manifest.json + token: ${{ secrets.GITHUB_TOKEN }} - # ── Re-dispatch ci.yaml for each freshly-created tag ────────── - # - # release-please runs under the default GITHUB_TOKEN, so the tag it - # creates (e.g. v0.2.0-rc) does NOT auto-trigger ci.yaml's - # `on: push: tags:` listener — GitHub suppresses workflow chaining - # from GITHUB_TOKEN-created refs to prevent infinite recursion. - # Without this job, rc / stable tags never produce OCI image or - # chart artifacts; only the moving `head` pushed on main-branch - # commits ever reaches GHCR. - # - # `gh workflow run --ref ` IS allowed from GITHUB_TOKEN because - # workflow_dispatch has an explicit audit trail and isn't prone to - # the same recursion class. The dispatched run sees - # github.ref=refs/tags/ and github.event_name=workflow_dispatch, - # which ci.yaml's `versions` job handles identically to a `push` tag - # event (the tag-ref check runs before the event-name branches). - dispatch-publish: - needs: release-please - if: needs.release-please.outputs.releases_created == 'true' - runs-on: ubuntu-latest - steps: + # `release-as` per-package is a one-shot: once the release PR it + # triggered has merged and the manifest has caught up, the override + # must be removed, otherwise every subsequent release-please run + # keeps pinning the same version and blocks normal semver bumps + # from feat:/fix: commits. This guard runs *after* release-please + # and is skipped whenever `releases_created=true`, so the + # tag/release finalization that happens on the merge-of-chore-PR + # push (where manifest has just caught up to release-as) isn't + # blocked. On any subsequent push where the state is still stale + # and there's nothing to release, the workflow fails loudly to + # force the cleanup PR. - uses: actions/checkout@v6 - with: - # Full history + tags needed for the highest-stable computation - # in the "Promote graduated release" step below. - fetch-depth: 0 - - - name: Ensure ci.yaml has a workflow_dispatch trigger + if: steps.rp.outputs.releases_created != 'true' + - name: Guard against stale `release-as` entries + if: steps.rp.outputs.releases_created != 'true' run: | - if ! grep -q 'workflow_dispatch' .github/workflows/ci.yaml; then - echo "::error::.github/workflows/ci.yaml must declare 'workflow_dispatch' for this job to dispatch it" >&2 - exit 1 - fi + python3 - <<'PY' + import json, sys + with open("release-please-config.json") as f: + cfg = json.load(f) + with open(".release-please-manifest.json") as f: + manifest = json.load(f) + stale = [] + for pkg, conf in cfg.get("packages", {}).items(): + ra = conf.get("release-as") + if ra is not None and manifest.get(pkg) == ra: + stale.append((pkg, ra)) + if stale: + print("::error::Stale `release-as` entries in release-please-config.json:") + for pkg, ra in stale: + print(f"::error:: packages.{pkg}.release-as={ra} already matches .release-please-manifest.json — remove this field so release-please can resume normal semver bumps.") + sys.exit(1) + PY - - name: Dispatch ci.yaml on the just-created tag - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ needs.release-please.outputs.tag_name }} - run: | - set -euo pipefail - if [[ -z "${TAG}" ]]; then - echo "::warning::release-please reported a release but no tag_name output; skipping dispatch" - exit 0 - fi - echo "Dispatching ci.yaml on tag ref: ${TAG}" - gh workflow run ci.yaml --ref "${TAG}" - - # ── Promote graduated releases from Pre-release → Latest ──── - # - # release-please-config.json sets repo-level `"prerelease": true`, - # which makes release-please mark EVERY GitHub release it creates - # (rc AND graduated full-release) with prerelease=true. That is - # correct for rc tags (vX.Y.Z-rcN) but wrong for graduated stable - # tags (vX.Y.Z), which should appear as full releases in the - # GitHub UI and — iff this is the highest stable semver — as - # "Latest release". - # - # We fix this after the fact: whenever the just-created tag has - # no prerelease suffix (no `-` in the version), flip the GitHub - # release's `prerelease` flag to false and, iff this is the - # highest stable semver, set it as the repo's "Latest release". - # This mirrors the OCI-publish logic in ci.yaml's `versions` job - # exactly (same tag glob, same `-`-filter, same `sort -V | tail`), - # so the GitHub release's "Latest" flag tracks the image/chart - # `:latest` pointer. - - name: Promote graduated release to full release + # Releases created via GITHUB_TOKEN do NOT trigger workflows that listen + # on `release: published` or `push: tags:`. Dispatch helm-publish and + # release-retag explicitly for each released tag. + dispatch-publish: + needs: release + if: needs.release.outputs.releases_created == 'true' + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + steps: + - name: Dispatch downstream workflows for each released tag env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ needs.release-please.outputs.tag_name }} + GH_REPO: ${{ github.repository }} + RP_OUTPUTS: ${{ needs.release.outputs.outputs }} + PATHS_RELEASED: ${{ needs.release.outputs.paths_released }} run: | set -euo pipefail - if [[ -z "${TAG}" ]]; then - echo "::warning::release-please reported a release but no tag_name output; skipping promote" - exit 0 - fi - VERSION="${TAG#v}" - if [[ "${VERSION}" == *-* ]]; then - echo "Tag ${TAG} is a prerelease (contains '-'); leaving GitHub release flags as-is." - exit 0 - fi - - HIGHEST_STABLE=$(git tag -l "v[0-9]*.[0-9]*.[0-9]*" \ - | grep -v -- '-' \ - | sed "s/^v//" \ - | sort -V \ - | tail -n1) - - if [[ "${VERSION}" == "${HIGHEST_STABLE}" ]]; then - echo "Promoting ${TAG}: prerelease=false, latest=true (highest stable)." - gh release edit "${TAG}" --prerelease=false --latest=true - else - echo "Promoting ${TAG}: prerelease=false, latest=false (backport below highest stable ${HIGHEST_STABLE})." - gh release edit "${TAG}" --prerelease=false --latest=false - fi + for path in $(jq -r '.[]' <<<"${PATHS_RELEASED}"); do + tag=$(jq -r --arg key "${path}--tag_name" '.[$key] // empty' <<<"${RP_OUTPUTS}") + if [[ -z "${tag}" ]]; then + echo "::warning::No tag_name output for path '${path}', skipping" + continue + fi + echo "Dispatching helm-publish.yaml for ${tag}" + gh workflow run helm-publish.yaml \ + --ref "${tag}" \ + -f tag_name="${tag}" + echo "Dispatching release-retag.yaml for ${tag}" + gh workflow run release-retag.yaml \ + --ref "${tag}" \ + -f tag_name="${tag}" + done diff --git a/.github/workflows/release-retag.yaml b/.github/workflows/release-retag.yaml new file mode 100644 index 0000000..376f4c6 --- /dev/null +++ b/.github/workflows/release-retag.yaml @@ -0,0 +1,146 @@ +name: release-images + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag_name: + description: "Release tag to retag (e.g. v1.2.3)" + required: true + type: string + +env: + REGISTRY: ghcr.io + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +permissions: + contents: read + packages: write + id-token: write # Cosign keyless (Sigstore OIDC) + +jobs: + retag: + runs-on: ubuntu-latest + steps: + - name: Parse release tag + id: parse + env: + TAG: ${{ inputs.tag_name || github.event.release.tag_name }} + run: | + if [[ "$TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "version=${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" >> $GITHUB_OUTPUT + echo "match=true" >> $GITHUB_OUTPUT + else + echo "match=false" >> $GITHUB_OUTPUT + fi + + - uses: actions/checkout@v6 + if: steps.parse.outputs.match == 'true' + with: + ref: ${{ inputs.tag_name || github.event.release.tag_name }} + fetch-depth: 0 + fetch-tags: true + + - name: Check if this is the highest stable release + id: highest + if: steps.parse.outputs.match == 'true' + shell: bash + run: | + VERSION=${{ steps.parse.outputs.version }} + + # List all stable (non-prerelease) tags, strip the prefix, + # sort by semver, and take the highest. + HIGHEST=$(git tag -l "v[0-9]*.[0-9]*.[0-9]*" \ + | sed "s/^v//" \ + | grep -v -- '-' \ + | sort -V \ + | tail -n1) + + if [[ "${VERSION}" == "${HIGHEST}" ]]; then + echo "is_highest=true" >> "$GITHUB_OUTPUT" + echo "${VERSION} is the highest stable release — will tag as latest." + else + echo "is_highest=false" >> "$GITHUB_OUTPUT" + echo "${VERSION} is NOT the highest stable release (highest: ${HIGHEST}) — skipping latest tag." + fi + + - name: Resolve tag commit SHA + if: steps.parse.outputs.match == 'true' + id: sha + env: + TAG: ${{ inputs.tag_name || github.event.release.tag_name }} + run: | + SHA=$(git rev-list -n1 "${TAG}") + echo "sha=${SHA}" >> $GITHUB_OUTPUT + + - uses: docker/setup-buildx-action@v4 + if: steps.parse.outputs.match == 'true' + + - uses: docker/login-action@v4 + if: steps.parse.outputs.match == 'true' + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Wait for source image + if: steps.parse.outputs.match == 'true' + env: + IMAGE: ${{ env.REGISTRY }}/${{ github.repository }}/haproxy-operator + SHA: ${{ steps.sha.outputs.sha }} + run: | + SRC="${IMAGE}:sha-${SHA}" + # The ci.yaml build for the same commit runs in parallel with + # release-please's dispatch of this workflow. Poll with + # exponential backoff until the sha-tagged image appears in + # the registry (or time out after ~30 min). + DELAY=10 + MAX_DELAY=60 + DEADLINE=$(( $(date +%s) + 1800 )) + while :; do + if docker buildx imagetools inspect "${SRC}" >/dev/null 2>&1; then + echo "Source image ${SRC} is available." + break + fi + if [[ $(date +%s) -ge $DEADLINE ]]; then + echo "::error::Timed out after 30m waiting for ${SRC} (CI build for $SHA did not publish)." + exit 1 + fi + echo "Source image ${SRC} not yet available; retrying in ${DELAY}s..." + sleep "$DELAY" + DELAY=$(( DELAY * 2 )) + if (( DELAY > MAX_DELAY )); then DELAY=$MAX_DELAY; fi + done + + - name: Retag (no pull) + id: retag + if: steps.parse.outputs.match == 'true' + run: | + IMAGE=${{ env.REGISTRY }}/${{ github.repository }}/haproxy-operator + SHA=${{ steps.sha.outputs.sha }} + VERSION=${{ steps.parse.outputs.version }} + + TAGS="-t ${IMAGE}:${VERSION}" + if [[ "${{ steps.highest.outputs.is_highest }}" == "true" ]]; then + TAGS="${TAGS} -t ${IMAGE}:latest" + fi + + docker buildx imagetools create \ + ${TAGS} \ + ${IMAGE}:sha-${SHA} + + # Capture digest for Cosign signing. + DIGEST=$(docker buildx imagetools inspect "${IMAGE}:${VERSION}" --format '{{.Manifest.Digest}}') + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + + # ── Sign with Cosign (keyless / Sigstore) ────────────── + - uses: sigstore/cosign-installer@v3 + if: steps.parse.outputs.match == 'true' + + - name: Sign release image + if: steps.parse.outputs.match == 'true' + run: | + cosign sign --yes \ + ${{ steps.retag.outputs.image }}@${{ steps.retag.outputs.digest }} diff --git a/release-please-config.json b/release-please-config.json index cbb1278..2a2355c 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -3,9 +3,6 @@ "bump-minor-pre-major": true, "include-component-in-tag": false, "separate-pull-requests": false, - "versioning": "prerelease", - "prerelease": true, - "prerelease-type": "rc", "packages": { ".": { "release-type": "simple", From c7d83362eba0d91ab9fa4c2c5ab74bb81a517176 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:16:49 +0000 Subject: [PATCH 2/3] fix(ci): always build on main pushes, add actions:read permission Address Devin Review findings: 1. Always build on main pushes so release-please merge commits (which only touch CHANGELOG/manifest/Chart.yaml, not Go source) still produce a sha- image that release-retag.yaml can find. Change detection is now only used to skip unnecessary PR builds. 2. Add actions:read permission to the build job, required by codeql-action/upload-sarif on private repos. Co-Authored-By: kyle_hunter@bcit.ca --- .github/workflows/ci.yaml | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d516cf4..46be155 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -93,6 +93,7 @@ jobs: packages: write id-token: write # Cosign keyless (Sigstore OIDC) security-events: write # Trivy SARIF upload to GitHub Security tab + actions: read # Required by codeql-action/upload-sarif on private repos steps: - name: Check quality gate shell: bash @@ -114,19 +115,21 @@ jobs: run: | set -euo pipefail - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - BASE="${{ github.event.pull_request.base.sha }}" - HEAD="${{ github.event.pull_request.head.sha }}" - else - BASE="${{ github.event.before }}" - HEAD="${{ github.sha }}" + # Always build on main pushes — release-please merge commits + # only touch CHANGELOG.md, manifest, and Chart.yaml (not Go + # source), but release-retag.yaml still needs a sha- + # image for the tagged commit. Change detection is only used + # to skip unnecessary PR builds. + if [[ "${{ github.event_name }}" != "pull_request" ]]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + exit 0 fi - # `github.event.before` is the all-zeroes SHA on first pushes - # to a new ref (and occasionally missing from the local clone - # on force-pushes / history edits). Fall back to HEAD~1 when - # available so `git diff` doesn't abort the job; if HEAD has - # no parent (initial commit), treat everything as changed. + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + + # Fall back to HEAD~1 when the base SHA is missing or + # unavailable in the local clone. ZERO_SHA="0000000000000000000000000000000000000000" if [[ -z "${BASE}" || "${BASE}" == "${ZERO_SHA}" ]] \ || ! git cat-file -e "${BASE}^{commit}" 2>/dev/null; then From 1de4c8c09e070975b831f2dbdda7da8464d50100 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:25:51 +0000 Subject: [PATCH 3/3] fix(ci): respect bump-minor-pre-major, fix image.version label 1. Version computation now mirrors release-please's bump-minor-pre-major setting: while MAJOR==0, breaking changes bump minor instead of major, keeping CI rc tags in sync with actual releases. 2. OCI image.version label now includes the timestamp component (-rc..) matching the published tag exactly. Co-Authored-By: kyle_hunter@bcit.ca --- .github/workflows/ci.yaml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 46be155..397f8c8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -189,7 +189,14 @@ jobs: if echo "$SUBJECTS" | grep -qE "^[a-zA-Z]+(\([^)]+\))?!:" \ || echo "$BODIES" | grep -qE "^BREAKING[ -]CHANGE:"; then - ((MAJOR+=1)); MINOR=0; PATCH=0 + # Mirror release-please's bump-minor-pre-major: while the + # project is still on major 0, breaking changes bump minor + # instead of major so CI tags stay in sync with releases. + if ((MAJOR == 0)); then + ((MINOR+=1)); PATCH=0 + else + ((MAJOR+=1)); MINOR=0; PATCH=0 + fi elif echo "$SUBJECTS" | grep -qE "^feat(\(.+\))?:"; then ((MINOR+=1)); PATCH=0 else @@ -237,7 +244,7 @@ jobs: labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.version=${{ steps.version.outputs.next }}-rc.${{ steps.version.outputs.short }} + org.opencontainers.image.version=${{ steps.version.outputs.next }}-rc.${{ steps.version.outputs.ts }}.${{ steps.version.outputs.short }} tags: | ${{ env.REGISTRY }}/${{ github.repository }}/haproxy-operator:sha-${{ github.sha }} ${{ env.REGISTRY }}/${{ github.repository }}/haproxy-operator:${{ steps.version.outputs.next }}-rc.${{ steps.version.outputs.ts }}.${{ steps.version.outputs.short }}