Skip to content

feat(release): submit the operator bundle to both catalogs on every release (#656) - #660

Merged
cevheri merged 2 commits into
mainfrom
656-automate-operator-catalog-submissions
Sep 8, 2026
Merged

feat(release): submit the operator bundle to both catalogs on every release (#656)#660
cevheri merged 2 commits into
mainfrom
656-automate-operator-catalog-submissions

Conversation

@cevheri

@cevheri cevheri commented Sep 8, 2026

Copy link
Copy Markdown
Member

Closes #656.

Every release now opens its own bundle pull request against both community operator catalogs.
Until today that was two hand-made submissions per release, and the cost was measurable rather than theoretical: both catalogs sat at 0.9.59 while the product reached 0.14.1, so 0.14.0 was never submitted at all.

What lands

  • submit-catalogs in .github/workflows/operator-release.yml, a matrix over the two catalogs with needs: build-and-push, because the catalog pipelines pull the controller image onto a real cluster and it has to exist and be public first.
  • scripts/operator-catalog-submission.mjs: every decision as a pure function, with the CLI as a thin shell. 50 tests.
  • operatorhub-community joins SWITCHABLE_CHANNEL_IDS, so the submission is switched from distribution/channels.yaml like every other optional channel.
  • spec.replaces is removed from the committed bundle, and its absence is now asserted.

The design decision, and why it reversed

The pointer's only correct value names the version immediately preceding ours in the catalog being submitted to.
That is external state: it lags our releases by however many submissions are unmerged, and the two catalogs disagree with each other.
CATALOG_REPLACES in operator/Makefile declared it, which made the committed bundle correct only by coincidence and stale by default - a cost paid on every release, and exactly the fourth hand-maintained version string the operator docs say must not exist.

So the value is derived at submission time from the catalog itself and injected into the bundle copy, and nothing in the repo declares it.
The consequence looks like an omission and is not: the committed bundle on its own does not pass opm index add --mode replaces.
tests/unit/operator-bundle-update-graph.test.ts asserts that absence rather than documenting it, because the obvious "fix" is to add a declared value back.

What was measured, not assumed

Two upstream gates reject an unlinked graph, and neither accepts olm.skipRange as a substitute:

Gate Failure with skipRange alone
k8s-operatorhub deploy jobs, opm index add --mode replaces add prunes bundle libredb-studio-operator.v0.9.59 ... skips/replaces []
community-operators-prod FBC, opm validate multiple channel heads found in graph: v0.14.1, v0.9.59

The first was reproduced locally against the same command with a local registry and two bundle images: replaces-mode with a skipRange alone exits 1, with spec.replaces exits 0, and --mode semver exits 0.
The second is from community-operators-prod#11106's pipeline log.
Note the trap in the pair: check_dangling_bundles in operatorcert seeds its graph from _resolve_skip_range and so passes on a skipRange alone, and it is decorated @skip_fbc so it never runs for the FBC catalog. A green static check is not evidence that a submission will build.

Three more findings that shaped the code:

  • Duplicate detection is path-based, never title-based. The hub rewrites submission titles on open and on every push, inserting markers in a fixed order and listing more than one version for a multi-version PR; our own #8794 was renamed to operator [N] [CI] libredb-studio-operator (0.9.59). The job reads each candidate PR's changed files instead.
  • gh repo sync --source cannot be trusted. It calls POST /merge-upstream first, and that endpoint takes neither a source nor a force parameter, so a fork pointing at the wrong parent would sync from the wrong repository and still exit 0. The job asserts the parent, calls merge-upstream directly, then asserts the fork is identical or behind.
  • Identity, not fork ownership, is what upstream authorizes. Both catalogs compare the account that opened the PR against the operator's ci.yaml reviewers on the base branch; nothing reads the fork owner or the commit author. OPERATOR_CATALOG_TOKEN is a classic PAT on a listed account, and signoff: true writes the trailer from the committer input, so committer and author are both set to the same real identity.

An older unmerged submission is a hard stop

The predecessor comes from the catalog's main, so a submission that is open but unmerged is invisible to it.
Submitting past a pending version points the graph over it, and once both merge the pending bundle is dangling.
That is what lost VictoriaMetrics 0.48.2, and their record is why it is worth a hard stop rather than a warning: victoriametrics-bot landed 45 of 50 submissions on operatorhub.io, and four of the five that did not land are versions the catalog has never carried.

Skips are notices, never failures

Every "nothing to do" branch exits 0 with a ::notice:: naming the condition: channel switched off, token absent, image not anonymously pullable, operator not listed yet (a first listing stays manual), version already listed, older submission still open.
Two things it refuses rather than guesses: a GitHub API read that fails stops the job instead of defaulting to "submit", and a fork with the wrong parent stops it before anything is mutated.

One honesty fix came out of this: docs/CHANNELS.md labelled everything that is not ci_publish as "Manual". The row now reads "Automated PR", because we open the pull request and somebody else merges it.

Verification

  • bun run format, lint (0 errors), typecheck, chart:check, channels:showcase:check, readme:check, security:check, distribution:matrix --check, knip: clean.
  • Tests run per layer: 10968 pass in unit/api/integration, and hooks, security, evals and components all clean. The one red is pre-existing and invisible to CI: gitignored docs/superpowers/ drafts trip the backlog citation guard.
  • make -C operator bundle regenerates with no diff beyond createdAt, so the required bundle-freshness job stays green.
  • The committed bundle was built as an image and run through opm index add --mode replaces to confirm it fails, which is the intended state.

Review

Two workflow fan-outs ran over this before it was opened: a recon pass on the upstream assumptions and a six-lens adversarial review of the diff, each finding verified by a second agent against the real code.
The review confirmed 44 defects and every one is fixed here. The six that mattered most:

  • An unrelated upstream pull request touching an already-merged version directory muted the submission. blockingSubmissions filtered only on "not our version", so any open PR editing operators/libredb-studio-operator/0.9.59/ would have stopped every release with a green run. A version the catalog already carries is no longer treated as pending.
  • A release below the channel head was allowed through and would have produced the two-head graph it exists to prevent. It is now refused, because linking that graph is a judgement call rather than a derivation.
  • readOperatorEntries swallowed every error, so a changed upstream layout or a wrong path read as "first submission is manual" and exited 0. Only a missing path means that now; anything else throws.
  • The GHCR check could not print its own diagnostic. ghcr hands a pull token to anyone who asks, so a private package fails on the manifest read, and curl -sf killed the step at exit 22 before the "make it public" message could run. It branches on the status code now, and retries transient failures.
  • Test gaps that let real mutations survive. compareVersions was never compared at patch level, the withReplaces anchor was never run against the real 380-line CSV, and the three spec.replaces absence guards had no positive control, so a broken helper would have satisfied all of them.
  • A prerelease tag would have reddened the release after the image was already pushed. The version step now emits submittable, and the submission job skips a prerelease instead of failing on it.

Also fixed: the workflow is serialized so two releases cannot race the pending-submission guard, the staged bundle is asserted to be the bundle for this version, a skip that means a release did not reach a catalog is a ::warning:: rather than a ::notice::, the skip names the blocking pull request number, the token is no longer handed to steps that only do unauthenticated reads, and the docs no longer contradict the code about skipRange.

…elease (#656)

Every release now opens its own bundle pull request against
k8s-operatorhub/community-operators and community-operators-prod. Until now
that was two hand-made submissions per release, and both catalogs sat at
0.9.59 while the product reached 0.14.1, so 0.14.0 was never submitted.

spec.replaces leaves the committed bundle. Its only correct value names the
version immediately preceding ours in the catalog being submitted to, which is
external state this repo cannot derive, so CATALOG_REPLACES made the bundle
correct only by coincidence and stale by default. The value is derived from
the catalog at submission time and injected into the bundle copy; the absence
is asserted so it cannot be reintroduced.

Both upstream gates reject an unlinked graph and neither accepts olm.skipRange
as a substitute: opm index add --mode replaces prunes the previous bundle on
the hub, and opm validate reports "multiple channel heads found in graph" on
the FBC side. Detection of a pending submission is path-based, because the hub
rewrites submission titles on every push.
@cevheri cevheri added enhancement New feature or request deployment PaaS / one-click deployment platform integrations and listings github-actions GitHub Actions workflow dependencies labels Sep 8, 2026
@cevheri
cevheri requested a balanced review from Copilot September 8, 2026 16:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Release concurrency can discard queued releases, and same-version pull requests from other branches can produce duplicate submissions.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Automates per-release OLM bundle submissions to both community operator catalogs while deriving catalog-specific update-graph predecessors.

Changes:

  • Adds the catalog submission workflow and decision helper.
  • Moves spec.replaces injection to submission time.
  • Updates channel inventory, documentation, and tests.
File summaries
File Description
.github/workflows/operator-release.yml Adds catalog submission jobs and release serialization.
scripts/operator-catalog-submission.mjs Implements submission decisions and bundle patching.
scripts/distribution-check.mjs Adds the channel switch and automated-PR status.
distribution/channels.yaml Enables automated operator catalog submissions.
operator/Makefile Removes repository-owned replaces stamping.
operator/config/manifests/bases/libredb-studio-operator.clusterserviceversion.yaml Removes the base placeholder.
operator/bundle/manifests/libredb-studio-operator.clusterserviceversion.yaml Removes the committed predecessor pointer.
docs/DISTRIBUTION.md Documents automation and update-graph behavior.
docs/CHANNELS.md Marks the channel as an automated PR.
tests/unit/operator-catalog-submission.test.ts Tests decision logic and CLI behavior.
tests/unit/operator-bundle-update-graph.test.ts Asserts that committed bundles omit replaces.
tests/unit/distribution-check.test.ts Tests switch and summary behavior.
Review details
  • Files reviewed: 12/12 changed files
  • Comments generated: 4
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/workflows/operator-release.yml Outdated
# submission's "an older submission is still open" guard, which reads the
# catalog's default branch and so cannot see a submission opened seconds
# earlier by the other run.
group: operator-release
Comment thread scripts/operator-catalog-submission.mjs Outdated
const blockers = new Map();
for (const submission of openSubmissions) {
for (const found of submission.versions) {
if (found !== version && !blockers.has(found)) {
Comment on lines +251 to +253
401|403)
echo "::error::${IMAGE}:${VERSION} is not anonymously pullable (HTTP ${CODE}). The catalog CI pulls without credentials - make the GHCR package public: github.com/orgs/libredb/packages"
exit 1 ;;
Comment thread docs/DISTRIBUTION.md
### Manual steps still open

- **Operator first listings — done.** The controller image was the first half:
- **Operator first listings and per-release submissions: done.**
…urrency

Three defects from review, all in the submission path.

A global concurrency group would have dropped a release outright: GitHub keeps
one pending run per group, so a third release cancels the second while the
first runs, whatever cancel-in-progress says, and a cancelled operator-release
means no image and no submission for that version. Per-ref is restored. The
race it was meant to serialize is loud instead: two submissions naming the same
predecessor fail the second merge upstream with "multiple channel heads".

The same-version exemption opened the duplicate it exists to prevent.
create-pull-request only ever updates its own branch, so an open pull request
for our version on any other branch or fork is a second submission. Only the
managed branch on our own fork is exempt now, which needs the pull request's
head and so a second read per candidate.

The forks moved to the libredb organization by transfer, which is what keeps
parent pointing at the upstream, and the matrix wiring is now asserted offline:
each fork paired with the fork of its own upstream, org-owned, release_config a
boolean on the FBC catalog only.
@cevheri

cevheri commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

All four addressed, two of them real defects. Thanks.

Concurrency. Right, and worse than the race it was guarding. GitHub keeps one pending run per group, so a third release cancels the second while the first runs, cancel-in-progress: false or not, and a cancelled operator-release means no image and no submission for that version. Reverted to operator-release-${{ github.ref }}. The race is now handled by being loud rather than serialized: two submissions naming the same predecessor fail the second merge upstream with multiple channel heads found in graph, which is the same signal that caught community-operators-prod#11106.

Same-version exemption. Correct, and it opened the duplicate it exists to prevent. isManagedSubmission now exempts only our own fork plus the branch create-pull-request pushes; a same-version pull request from any other branch or fork blocks and is named in the skip. That needs the pull request head, so readOpenSubmissions does a second read per candidate. Six new tests, including a CLI one where our own rerun proceeds and a stranger's identical branch name on another fork blocks.

Image visibility. The workflow was right and the description was wrong. A private GHCR package breaks the channel silently, so it stays a hard failure; the docs now list the three conditions that fail rather than skip: image not anonymously pullable, fork parent not the upstream, GitHub API read failed.

Stale status text. Fixed. Both catalogs now carry 0.14.1 (k8s-operatorhub/community-operators#9219 and community-operators-prod#11106), and the manual release-config.yaml instruction is gone since the job writes it.

One note on the failing check: SonarCloud Analysis died on Failed to ListArtifacts: (403) Forbidden: Error from intermediary. The coverage artifact is present in the run and the job's needs: [test] is correct, so that is infrastructure rather than this change.

@cevheri cevheri self-assigned this Sep 8, 2026
@cevheri
cevheri requested a balanced review from Copilot September 8, 2026 16:49
@sonarqubecloud

sonarqubecloud Bot commented Sep 8, 2026

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The fork comparison is malformed, missing parent paths can silently skip submissions, and releases remain vulnerable to the documented concurrency race.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 12/12 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment on lines +25 to +28
# Keyed on the ref on purpose, even though a global group would serialize
# the submission's "an open submission for another version" guard, which
# reads the catalog's default branch and so cannot see a pull request opened
# seconds earlier by another run.
# catalog's default branch and fails loudly if it cannot, so a fork
# whose main carries commits the upstream lacks has to stop us here
# rather than upstream.
STATUS=$(gh api "repos/${UPSTREAM}/compare/main...${FORK%%/*}:${FORK##*/}:main" --jq .status)
Comment on lines +243 to +244
if (error.code === "ENOENT") {
return null;
@cevheri
cevheri merged commit efcc0c2 into main Sep 8, 2026
25 checks passed
@cevheri

cevheri commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Second round: one real defect, one claim that does not hold, one that is about this description rather than the code. Thanks for the ENOENT one, it is a genuine hole.

readOperatorEntries and a missing parent: right, and fixed. readdir raises ENOENT for a missing parent too, so "operators/ is gone" and "this operator has no directory yet" arrived as the same error, and an upstream rename would have green-skipped every release. It now checks the parent is a directory before treating a missing leaf as a first listing, and throws otherwise. Two tests, including the control where the parent exists and the leaf does not, which must still report a manual first listing. The old fixture for that case was modelling a broken checkout rather than an intact one and has been corrected.

The compare ref is not malformed. Measured against both real repositories just now:

GET /repos/k8s-operatorhub/community-operators/compare/main...libredb:community-operators:main
  -> status=behind ahead=0 behind=3
GET /repos/redhat-openshift-ecosystem/community-operators-prod/compare/main...libredb:community-operators-prod:main
  -> status=behind behind=1

The compare endpoint accepts <owner>:<repo>:<branch> as well as <owner>:<branch>; the three-part form is the one that is unambiguous when the fork has been renamed, which is why it is used.

Concurrency: the code is deliberate and this description was stale. You are right that operator-release-${{ github.ref }} does not serialize two different tags. It was a global group in the first push and that was worse: GitHub keeps one pending run per group, so a third release cancels the second while the first runs, whatever cancel-in-progress says, and a cancelled operator-release means no image and no submission for that version, silently. The race that remains is loud rather than silent: two submissions naming the same predecessor fail the second merge upstream with multiple channel heads found in graph, which is exactly the signal that caught community-operators-prod#11106. A lock that does not discard pending runs has no GitHub primitive, so it would mean a third-party mutex action holding a branch lock, which is more machinery than a rare and loudly-caught race justifies. The reasoning is recorded in the workflow next to the group. The claim in this description is what was wrong, and the follow-up commit corrects the record.

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

Labels

deployment PaaS / one-click deployment platform integrations and listings enhancement New feature or request github-actions GitHub Actions workflow dependencies

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Automate the operator catalog submissions on release (both community catalogs)

2 participants