Skip to content

ci: publish signed cluster OCI artifact - #2163

Open
jfroy wants to merge 1 commit into
mainfrom
oci-release
Open

ci: publish signed cluster OCI artifact#2163
jfroy wants to merge 1 commit into
mainfrom
oci-release

Conversation

@jfroy

@jfroy jfroy commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Summary

  • build and publish the kubernetes/ tree as an OCI artifact on pushes to main
  • publish an immutable SHA tag to GHCR
  • keylessly sign and verify the artifact with GitHub OIDC/Cosign
  • promote the verified digest to latest
  • reject workflow_dispatch runs from non-main refs

Rollout gate

This is step 1 of the OCI migration. After merge, confirm the Cluster Release workflow succeeds and ghcr.io/jfroy/flatops/cluster:latest exists before merging the next PR. This seeds the signed artifact without changing Flux.

Stack

  1. This PR: publish and seed the signed artifact
  2. Bootstrap a verified OCI source and OIDC Receiver
  3. Migrate default-namespace Kustomizations
  4. Complete the source migration and transfer source ownership to FluxInstance

Summary by CodeRabbit

  • Chores
    • Improved the cluster artifact release process with automated build, validation, signing, and promotion steps.
    • Releases now publish verified, immutable artifacts and maintain a reliable latest reference.
    • Added safeguards to ensure releases are produced only from the designated main code line.

@daddy-ro

daddy-ro Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

konflate — summary

Note

✅ No rendered changes.

View the full rendered diff →

konflate · rendered 7de08b8 · advisory, not a gate

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@jfroy, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 28 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 03506b19-7d60-46ff-8a6b-d3eb05373117

📥 Commits

Reviewing files that changed from the base of the PR and between 2e15022 and 7de08b8.

📒 Files selected for processing (3)
  • .github/workflows/cluster-release.yaml
  • .renovate/customManagers.json5
  • .renovate/packageRules.json5
📝 Walkthrough

Walkthrough

The PR adds a GitHub Actions workflow for Kubernetes cluster artifact releases. The workflow publishes an OCI artifact to GHCR, signs and verifies its digest with Cosign, and promotes the verified digest to latest.

Changes

Cluster artifact release

Layer / File(s) Summary
Release workflow setup
.github/workflows/cluster-release.yaml
The workflow adds main-branch and Kubernetes-path triggers, concurrency controls, read-only defaults, release permissions, tool setup, and GHCR authentication.
OCI artifact publication
.github/workflows/cluster-release.yaml
The workflow copies Kubernetes content to a temporary directory and publishes a reproducible OCI artifact identified by the commit SHA. It exposes the resulting digest.
Artifact signing and promotion
.github/workflows/cluster-release.yaml
Cosign signs and verifies the artifact digest with the GitHub workflow certificate identity and Actions OIDC issuer. Flux applies the latest tag to the verified digest.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GitHubActions
  participant FluxCLI
  participant GHCR
  participant Cosign

  GitHubActions->>GitHubActions: Validate main ref and prepare tools
  GitHubActions->>GHCR: Authenticate
  GitHubActions->>FluxCLI: Push Kubernetes OCI artifact
  FluxCLI->>GHCR: Store artifact
  GHCR-->>GitHubActions: Return digest
  GitHubActions->>Cosign: Sign and verify digest with OIDC identity
  Cosign->>GHCR: Store and validate signature
  GitHubActions->>FluxCLI: Apply latest tag to verified digest
  FluxCLI->>GHCR: Promote digest to latest
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the workflow that publishes a signed cluster OCI artifact.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread .github/workflows/cluster-release.yaml Outdated
Comment thread .github/workflows/cluster-release.yaml Outdated
Comment thread .github/workflows/cluster-release.yaml Outdated
@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

The artifact layout is right — copying into ${ARTIFACT_DIR}/kubernetes/ preserves the kubernetes/ prefix that kubernetes/cluster/ks.yaml needs for its path: ./kubernetes/apps and ./kubernetes/vap, so steps 2–4 can point an OCIRepository at the same paths the GitRepository uses today. --revision main@sha1:${GITHUB_SHA} is the format Flux expects, the if: github.ref == 'refs/heads/main' guard does close the workflow_dispatch hole, and scoping DOCKER_CONFIG to an empty dir for the last step is a genuine anonymous test (go-containerregistry's default keychain honours DOCKER_CONFIG and does not fall back to $HOME/.docker).

Two things about the rollout gate itself:

Expect the first run to fail at Verify anonymous pull access. GHCR containers are created private; ghcr.io/jfroy/flatops/cluster does not exist yet, so this run creates it private and the unauthenticated pull gets a 401. Visibility has to be flipped to public in the package settings after the package first appears — there is no REST endpoint for it, and GITHUB_TOKEN cannot do it. So the gate as written is "run, watch it fail, flip visibility, re-dispatch." Worth calling that out in the PR description so the failure is not read as a bug in the workflow.

The gate does not currently cover the latest tag or anonymous signature retrieval — both are inline comments; those are the two preconditions step 2 actually depends on.

Nothing under kubernetes/ is affected by Flux's default sourceignore patterns (no images or archives in the tree), so the artifact will be complete.

@jfroy

jfroy commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

Addressed the review feedback:

  • removed the redundant Build cluster artifact step; staging now happens directly in Publish immutable artifact
  • removed Verify anonymous pull access
  • changed Cosign verification to use ${GITHUB_SERVER_URL}/${GITHUB_WORKFLOW_REF} instead of a hardcoded identity

Rebased and force-updated all downstream stack branches so their PR diffs remain 6 / 60 / 86 files.

Comment thread .github/workflows/cluster-release.yaml Outdated
Comment thread .github/workflows/cluster-release.yaml
@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

The artifact layout is right — copying into ${ARTIFACT_DIR}/kubernetes/ keeps kubernetes/ as a prefix inside the tar, so the path: ./kubernetes/apps and ./kubernetes/vap values in kubernetes/cluster/ks.yaml carry over unchanged when step 3 swaps sourceRef. --output json + .digest and the ${GITHUB_SERVER_URL}/${GITHUB_WORKFLOW_REF} certificate identity are both correct, and flux tag artifact re-tags the same manifest so the signature (keyed on digest) still resolves through latest. Two things flagged inline; the rest below is rollout-gate and nits.

Add to the rollout gate. A GHCR package created by GITHUB_TOKEN is private by default regardless of repo visibility, and nothing here changes that. So the check before merging PR 2 is not just ":latest exists" — it is either:

  • flip the package to public (Package settings → Change visibility), or
  • have PR 2 ship an imagePullSecret on the OCIRepository, sourced from 1Password like every other secret in the repo.

If neither happens, PR 2 lands an OCIRepository that fails to pull and cluster-apps stops reconciling. Worth deciding which one now, since it changes PR 2s contents.

Nits, none blocking:

  • Line 66: --annotations "org.opencontainers.image.source=..." is redundant — flux push artifact already writes that annotation from --source, so this sets the same key to the same value.
  • Line 64 hardcodes https://github.com/ while line 77 uses ${GITHUB_SERVER_URL}; line 24 hardcodes jfroy/flatops where ghcr.io/${{ github.repository }}/cluster would do. Line 65 hardcodes main@sha1: — safe today only because of the line 28 guard.
  • permissions sits at workflow level here, whereas claude-review.yaml and label-sync.yaml both keep contents: read at the top and elevate per job. Moving id-token: write / packages: write under release: matches the existing shape.
  • Consider --reproducible on the push so identical kubernetes/ content yields a stable digest across re-runs — makes a workflow_dispatch re-run a no-op for Flux instead of a fresh revision.

@jfroy

jfroy commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

Addressed the second review round:

  • scope concurrency by ref and serialize releases with cancel-in-progress: false
  • move id-token: write and packages: write to the release job
  • derive the OCI repository, source URL, and revision from GitHub context
  • remove the redundant org.opencontainers.image.source annotation
  • add --reproducible to avoid producing a new digest when re-running the same commit

I also confirmed the live cluster runs source-controller:v1.9.4. Its Cosign verifier explicitly supports both Cosign v2 signatures and Cosign v3 bundles, including OCI 1.1 referrer and OCI 1.0 referrer-tag discovery, so keeping Cosign v3.1.3 is compatible with Flux verification.

All downstream stack branches were rebased and force-updated.

Comment thread .github/workflows/cluster-release.yaml
Comment thread .github/workflows/cluster-release.yaml Outdated
Comment thread .github/workflows/cluster-release.yaml Outdated
@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Nothing under kubernetes/ changes, so there is no Kustomize or Flux wiring risk in this PR, and nothing consumes the artifact yet — it is safe seeding as described.

I did verify the artifact layout against what steps 2–4 will need, since getting it wrong here would be invisible until the source swap:

  • Copying into ${ARTIFACT_DIR}/kubernetes/ rather than pushing kubernetes/ as the artifact root is the right call. It keeps spec.sync.path: kubernetes/cluster (kubernetes/apps/flux-system/instance/ks/helm-values.yaml) and the ./kubernetes/apps / ./kubernetes/vap paths in kubernetes/cluster/ks.yaml valid unchanged, so the OCIRepository swap needs no path edits.
  • All spec.components refs (../../../../components/...) resolve inside kubernetes/, not above it. No symlinks, no dotfiles, and no *.png/*.zip/*.tar.gz under kubernetes/, so Flux's default source-ignore rules will not silently drop anything from the tarball.
  • --source/--revision match the existing GitRepository (https://github.com/jfroy/flatops, main@sha1:<sha>), so Flux revision strings stay comparable across the migration.

Three things flagged inline; the cosign signature-format one is the one I would resolve before merging step 2, since the rollout gate as written cannot detect it.

One more, non-blocking: concurrency with cancel-in-progress: false does not guarantee an immutable tag per commit. GitHub keeps only one pending run per concurrency group, so with three merges to main in quick succession the middle run is cancelled and that commit never gets a :<sha> tag. latest still ends up on the newest commit (ordering is preserved), so this is a gap in per-commit immutable history rather than a correctness problem — but it is worth knowing before you rely on :<sha> tags for rollback.

@jfroy

jfroy commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

Addressed this review round:

  • explicitly pin the Cosign signature format with --new-bundle-format=true on both cosign sign and the existing cosign verify; per the review discussion, no additional referrer/tag probe was added
  • promote latest directly from the digest returned by flux push artifact, so promotion cannot resolve a changed SHA tag
  • replace the green job-level skip with a first step that fails non-main workflow dispatches loudly

The downstream branches were rebased and force-updated again. I also linked #2163, #2164, #2165, and #2161 using GitHub’s stacked pull request feature as stack #2166.

Comment thread .github/workflows/cluster-release.yaml Outdated
Comment on lines +82 to +87
cosign sign --yes --new-bundle-format=true "${OCI_REPOSITORY}@${DIGEST}"
cosign verify \
--new-bundle-format=true \
--certificate-identity "${GITHUB_SERVER_URL}/${GITHUB_WORKFLOW_REF}" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"${OCI_REPOSITORY}@${DIGEST}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--new-bundle-format=true will very likely produce a signature Flux cannot verify, which breaks step 2 of the stack.

With the new bundle format, cosign stores the signature as an OCI 1.1 referrer (artifactType: application/vnd.dev.sigstore.bundle.v0.3+json) attached to the subject digest. Flux's OCIRepository cosign verifier goes through cosign.VerifyImageSignatures with the legacy discovery path — it resolves the sha256-<digest>.sig tag and does not set NewBundleFormat in CheckOpts. No .sig tag is written when the new format is used, so verify.provider: cosign + matchOIDCIdentity (the pattern every existing OCIRepository in this repo uses, e.g. kubernetes/apps/kube-system/reloader/app/ocirepository.yaml) would fail with "no matching signatures".

The cosign verify on line 83 will not catch this — cosign auto-detects the format it just wrote. So the rollout gate in the PR description ("confirm the Cluster Release workflow succeeds") gives false confidence about whether Flux can consume the artifact.

Note that on cosign v3.x the new bundle format is already the default, so this has to be turned off explicitly:

Suggested change
cosign sign --yes --new-bundle-format=true "${OCI_REPOSITORY}@${DIGEST}"
cosign verify \
--new-bundle-format=true \
--certificate-identity "${GITHUB_SERVER_URL}/${GITHUB_WORKFLOW_REF}" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"${OCI_REPOSITORY}@${DIGEST}"
cosign sign --yes --new-bundle-format=false "${OCI_REPOSITORY}@${DIGEST}"
cosign verify \
--certificate-identity "${GITHUB_SERVER_URL}/${GITHUB_WORKFLOW_REF}" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"${OCI_REPOSITORY}@${DIGEST}"

Alternatively run cosign sign twice (once with each format) if you want the modern bundle available too. Either way, worth confirming against a real OCIRepository with verify.provider: cosign before merging step 2, rather than relying on the in-workflow verify.

(The --certificate-identity construction itself is right: $GITHUB_SERVER_URL/$GITHUB_WORKFLOW_REFhttps://github.com/jfroy/flatops/.github/workflows/cluster-release.yaml@refs/heads/main, which is what the step-2 matchOIDCIdentity.subject regex will need to match.)

Comment on lines +44 to +53
- name: Setup Flux CLI
uses: fluxcd/flux2/action@889be9d6cc8afa8ed639e1e1ba4ab678e3b38d8c # v2.9.4
with:
token: ${{ github.token }}
version: 2.9.4

- name: Setup Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: v3.1.3

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two pinned tool versions are outside Renovate's reach and will silently drift.

.renovate/customManagers.json5 scopes both custom managers to managerFilePatterns: ["/^(kubernetes|talos)/.+\\.yaml$/"], and Renovate's built-in github-actions manager only reads uses: refs — it does not look inside with: inputs. So helpers:pinGitHubActionDigestsToSemver will keep bumping the action digests while version: 2.9.4 and cosign-release: v3.1.3 stay frozen. After the next fluxcd/flux2/action bump you'd be running the new action to install an old flux CLI.

Two ways out:

  • Drop version:/cosign-release: and let each action install its default (latest) release — simplest, and matches the "no unnecessary knobs" style of the other two workflows.
  • Keep the pins and add # renovate: annotations plus a .github/workflows/ entry in the custom manager's managerFilePatterns, e.g.:
        with:
          token: ${{ github.token }}
          # renovate: datasource=github-releases depName=fluxcd/flux2
          version: 2.9.4

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Two inline comments, one of which I think blocks the next PR in the stack rather than this one.

Signature format vs. Fluxcosign sign --new-bundle-format=true almost certainly produces a signature Flux's OCIRepository verifier can't find (it looks for the legacy sha256-<digest>.sig tag). The cosign verify in the same step passes regardless, so the stated rollout gate won't surface it; you'd only find out when step 2's verify.provider: cosign starts failing. Details inline.

Renovate coverageversion: 2.9.4 and cosign-release: v3.1.3 are invisible to both the custom managers (scoped to ^(kubernetes|talos)/) and the built-in github-actions manager, so they'll drift from the digest pins above them.

The rest checks out:

  • Artifact layout is right — copying into ${ARTIFACT_DIR}/kubernetes/ preserves the kubernetes/ prefix that kubernetes/cluster/ks.yaml paths (./kubernetes/apps, ./kubernetes/vap) already depend on, so step 3 won't need path rewrites.
  • Nothing under kubernetes/ gets dropped by flux's default sourceignore — no symlinks, no hidden files, and no .jpg/.png/.zip/.tar.gz (the tree is 856 yaml, 16 json, 6 sh, 6 conf, 3 yml, and one each of toml/sql/js/ini/css).
  • latest is promoted from the digest, so the signature stays bound to it.
  • Signing a digest rather than a tag, id-token: write + packages: write scoped to the job, persist-credentials: false, and cancel-in-progress: false are all the right calls.

Two smaller notes:

  1. No render gate before promotion. Nothing here checks the tree actually builds before latest moves. Fine today since this PR doesn't change Flux and konflate covers PRs, but once step 3/4 makes latest the cluster's source of truth, an unrenderable tree pushed straight to main (or a merge whose resolution konflate never saw) promotes to latest and the cluster picks it up. A flux build kustomization / kustomize build pass over kubernetes/cluster between publish and promote would close that, and it's cheaper to add now than to retrofit after the cutover.

  2. Job name. Build, Sign, and Publish breaks the <Workflow Name> - <Job Purpose> convention the other two workflows follow (Label Sync - Sync Labels, Claude Review - Review Pull Request) — Cluster Release - Build, Sign, and Publish would match.

One thing I could not verify without network access in this environment: that flux push artifact --output json emits a bare .digest (sha256:…, not a full repo@sha256:… reference), and that flux tag artifact accepts a digest reference — the documented examples only show tag refs. Both fail loudly on the first run, so the rollout gate covers them; just noting they are assumptions rather than things I confirmed.

@jfroy

jfroy commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

Follow-up to the latest review:

  • pinned the signature to Cosign’s legacy sidecar format with --new-bundle-format=false on sign and the existing verify; no additional format-probing command was added
  • added Renovate annotations for the Flux and Cosign CLI pins and extended the existing release regex manager to scan workflow YAML
  • renamed the job to Cluster Release - Build, Sign, and Publish

I did not add a second render/build gate here; Konflate remains responsible for rendered PR validation, and the earlier redundant artifact-build step stays removed.

The force-pushed branches remain linked in GitHub stack #2166, in positions #2163#2164#2165#2161.

Comment on lines +48 to +49
# renovate: datasource=github-releases depName=fluxcd/flux2
version: 2.9.4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version: value here is unprefixed, but the custom manager you just pointed at .github/workflows/** will very likely rewrite it with a v prefix and break this step.

github-releases yields v-prefixed versions for fluxcd/flux2, and the custom manager's versioningTemplate falls back to semver-coerced, whose getNewValue returns the new version verbatim. Both existing github-releases annotations in the repo bear this out — planeVersion: v1.4.1 (kubernetes/apps/plane/plane/app/helmrelease.yaml:20) and the v0.14.0 release URL in kubernetes/apps/cnpg-system/barman-cloud/app/kustomization.yaml:7. The capture group v?\d[-a-zA-Z0-9._+]* happily matches 2.9.4 on read and won't stop v2.9.5 from being written back.

Since :automergeMinor is on, that bump auto-merges and the next Cluster Release run fails at Setup Flux CLI (the action interpolates version into the v${version} release path). The cosign-release: v3.1.3 annotation below is unaffected because cosign-installer wants the prefix.

Cleanest fix is a packageRule so the datasource hands back an unprefixed version:

{
  matchDatasources: ["github-releases"],
  matchPackageNames: ["fluxcd/flux2"],
  extractVersion: "^v(?<version>.+)$",
}

Worth checking before merge whether fluxcd/flux2/action tolerates a leading v — if it does, this is cosmetic; if it doesn't, the workflow silently stops publishing after the first Renovate bump, which is the failure mode the rollout gate won't catch.

Comment on lines +33 to +37
- name: Require main ref
if: github.ref != 'refs/heads/main'
run: |
echo "Cluster releases must run from refs/heads/main" >&2
exit 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guards the ref but not the commit, so it doesn't stop latest from moving backwards.

Re-running an earlier run ("Re-run all jobs" / "Re-run failed jobs") keeps that run's original GITHUB_SHA while github.ref is still refs/heads/main, so this check passes. The checkout is the old commit, the artifact is built from the old kubernetes/ tree, and Promote artifact re-points latest at it. That's harmless today, but once step 3/4 of the stack lands it is a silent cluster rollback — and it's a realistic path, since re-running a run that failed at Sign and verify artifact after main has advanced is exactly what someone would do.

Consider asserting that the checked-out commit is still the tip of main before promoting:

      - name: Require main tip
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          tip="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)"
          if [[ "${tip}" != "${GITHUB_SHA}" ]]; then
            echo "${GITHUB_SHA} is not the tip of main (${tip}); refusing to promote" >&2
            exit 1
          fi

Placing it just before Promote artifact still lets the immutable SHA tag and its signature be (re)published, and only blocks the latest move.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Two inline comments on the workflow. Beyond those, some notes on the rollout gate itself, since a few things can only fail on the first real run:

Add package visibility to the gate. ghcr.io/jfroy/flatops/cluster doesn't exist yet — nothing in the repo references it (only ghcr.io/jfroy/charts/zfs-static-csi shows up today). GHCR creates a brand-new package as private and unlinked from the repo, and the sha256-<digest>.sig tag inherits that. So "confirm ghcr.io/jfroy/flatops/cluster:latest exists" isn't sufficient for step 2 — the gate should also confirm the cluster can actually pull both the artifact and its signature, i.e. either flip the package to public or wire up a pull secret for the OCIRepository. Otherwise step 2 fails on 401 rather than on anything to do with verification.

Two CLI surfaces worth eyeballing in the first run's logs:

  • flux tag artifact "oci://…@${DIGEST}" — the documented form takes a tag, not a digest. Digest input should be fine (it parses as a reference and re-points the tag to the same descriptor), but confirm the latest tag ends up at the signed digest rather than at a freshly-pushed manifest, since that assumption is exactly what step 2's verify depends on.
  • cosign verify --new-bundle-format=false — the flag is unambiguous on sign; on verify, cosign v3 auto-detects the format, so double-check it isn't rejected as unknown. (Choosing the legacy format is right — source-controller reads the .sig tag.)

The kubernetes/ prefix in the artifact is the right call. I checked every relative reference under the tree: the deepest are ../../../../components/envoy-gateway-oidc and ../../../../components/kopiur/backup, which resolve to kubernetes/components/.... Rooting the artifact at kubernetes/ keeps those inside the artifact boundary and lets a future OCIRepository reuse path: kubernetes/cluster verbatim from instance/ks/helm-values.yaml. Flattening to the contents of kubernetes/ would have pushed those refs outside the root.

The paths filter is complete for what the artifact contains, so latest will lag main's HEAD on commits that don't touch kubernetes/**. That's fine, but worth knowing before step 4: Flux's reported revision won't always match main's tip.

Ordering (publish → sign → verify → promote) is right — latest only moves after verification — and --certificate-identity "${GITHUB_SERVER_URL}/${GITHUB_WORKFLOW_REF}" resolves to the exact SAN Fulcio issues, so step 2's matchOIDCIdentity can reuse that string as-is.

@jfroy

jfroy commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

Addressed this round:

  • kept the early Require main ref guard
  • added the recommended Require main tip check immediately before promotion; stale re-runs can still publish/sign their immutable SHA artifact, but cannot move latest
  • added a Renovate extractVersion rule for fluxcd/flux2 so updates remain unprefixed as required by fluxcd/flux2/action

I also checked the common alternatives for stale deployment prevention:

The API form from the review is the simplest fit here because gh is preinstalled and the job already has contents: read. All downstream branches were rebased and force-updated; GitHub stack #2166 remains intact.

Comment on lines +91 to +99
- name: Require main tip
env:
GH_TOKEN: ${{ github.token }}
run: |
tip="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)"
if [[ "${tip}" != "${GITHUB_SHA}" ]]; then
echo "${GITHUB_SHA} is not the tip of main (${tip}); refusing to promote" >&2
exit 1
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The strict tip equality check interacts badly with the paths: filter above and will block legitimate promotions.

paths restricts the trigger to kubernetes/** and this workflow file, so not every commit on main starts a release run — but every commit on main moves the tip. Sequence:

  1. Commit A touches kubernetes/apps/default/foo/ → release run starts, publishes and signs :A.
  2. Commit B lands while A is still running and touches only talos/talconfig.yaml, docs/, .renovate/, or README.md → no release run (filtered out).
  3. A's tip check sees tip == B != Aexit 1.

latest never gets A's content even though A is the current state of kubernetes/, and it stays stale until the next kubernetes/** push (or a manual workflow_dispatch). Given how often Renovate lands talos/ and .renovate/ commits here, this will fire regularly — and once step 2 of the stack points Flux at latest, a stale latest means the cluster silently stops picking up manifest changes with nothing to alert on.

Secondary issue: even in the genuine race (two kubernetes/** pushes back to back), exit 1 leaves a red run on main for a commit that was correctly superseded. That's a skip, not a failure.

Scoping the comparison to release-relevant paths and downgrading supersession to a skip handles both:

      - name: Resolve promotion
        id: promote
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          cmp="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${GITHUB_SHA}...main")"
          case "$(jq -er .status <<< "${cmp}")" in
            identical)
              echo "promote=true" >> "${GITHUB_OUTPUT}"
              ;;
            ahead)
              # Only a newer commit that changes the artifact should block promotion;
              # docs/talos/renovate commits move the tip without triggering a release.
              if jq -e '[(.files // [])[].filename]
                        | any(startswith("kubernetes/")
                              or . == ".github/workflows/cluster-release.yaml")' \
                   <<< "${cmp}" >/dev/null; then
                echo "superseded by a newer release commit; skipping promotion"
                echo "promote=false" >> "${GITHUB_OUTPUT}"
              else
                echo "promote=true" >> "${GITHUB_OUTPUT}"
              fi
              ;;
            *)
              echo "${GITHUB_SHA} is not an ancestor of main; refusing to promote" >&2
              exit 1
              ;;
          esac

with if: steps.promote.outputs.promote == 'true' on Promote artifact. This also keeps the behind/diverged cases as hard failures, which is the property the current check was presumably after.

(Note the compare API caps files at 300 — for a range that large you'd want the != 0 file-count fallback, though that's unlikely between two consecutive main commits.)

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

One blocking-ish issue in the promotion gate (inline on the Require main tip step) — the paths: filter means non-kubernetes/** commits move main's tip without triggering a release, so a strict tip-equality check will refuse to promote artifacts that are main's current manifest state.

A few rollout notes for the gate in the PR description:

GHCR package visibility. ghcr.io/jfroy/flatops/cluster doesn't exist yet, and GHCR creates new packages as private. The first run will succeed (packages: write + auto-linking to this repo covers push, sign, and the read-back in cosign verify), but step 2 of the stack can't bootstrap an OCIRepository against it until the package is either made public or given a secretRef pull secret. Worth folding into the gate checklist alongside ":latest exists" so it isn't discovered when the source is already committed.

The Renovate changes don't take effect in this PR. .renovaterc.json5 pulls these files in via github>jfroy/flatops//.renovate/..., which Renovate resolves from the default branch, not the PR head. So the new managerFilePatterns entry and the fluxcd/flux2 extractVersion rule are inert (and unvalidated) until merge — don't read a clean Renovate run on this PR as confirmation they parse. The extractVersion itself is the right call: fluxcd/flux2/action interpolates its version input into releases/download/v${VERSION}/flux_${VERSION}_..., so the unprefixed 2.9.4 is required, and scoping the rule to github-releases correctly avoids colliding with the github-tags lookup the github-actions manager uses for the pinned action digest.

Minor: the flux version is now two independent deps — the action digest (github-actions manager) and the CLI version (custom manager) — so each flux release yields two Renovate PRs that auto-merge separately. Drift between them is harmless (the action only downloads whatever version says), but a groupName in .renovate/groups.json5 matching fluxcd/flux2 across both datasources would halve the noise.

Also: nothing here validates the manifests, despite what the CodeRabbit summary claims. Not a regression — Flux tracks main directly today and konflate gates PRs — but a green Cluster Release shouldn't be read as a manifest gate once latest is what the cluster follows.

The rest checks out, and a couple of the non-obvious choices are right:

  • Nesting the tree under kubernetes/ in the artifact matches every existing spec.path: ./kubernetes/..., so no Kustomization paths change during the migration. I confirmed nothing under kubernetes/ uses relative paths that escape it (deepest is ../../../../components/... from apps/<ns>/<app>/app, which lands on kubernetes/components/), so the artifact is self-contained.
  • --new-bundle-format=false is the right choice for source-controller's cosign verifier.
  • ${GITHUB_SERVER_URL}/${GITHUB_WORKFLOW_REF} resolves to the exact SAN that keyless signing puts in the cert, including the @refs/heads/main suffix, so --certificate-identity holds for both push and workflow_dispatch.
  • --reproducible plus flux push on a fixed SHA tag makes re-runs idempotent, so a retried run won't orphan a differently-digested artifact.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant