Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ These instructions help AI agents work productively in this **Kubebuilder v4**-b
- [internal/reconciler/orgrec](internal/reconciler/orgrec) for organizations - manages org settings, custom properties, rulesets, code security configurations, and actions settings
- [internal/reconciler/reporec](internal/reconciler/reporec) for repositories - manages repo settings, webhooks, and rulesets
- [internal/reconciler/teamrec](internal/reconciler/teamrec) for teams - manages team creation, membership, and multi-organization support
- GitHub integration via a cached client factory using GitHub App credentials from a Kubernetes Secret; see [internal/ghclient/factory.go](internal/ghclient/factory.go), [internal/ghclient/wrapper.go](internal/ghclient/wrapper.go), [internal/ghclient/interface.go](internal/ghclient/interface.go).
- GitHub integration via a cached client factory that supports **multiple GitHub Apps** — each organization can reference its own credentials secret via `spec.githubAppConfig`; see [internal/ghclient/factory.go](internal/ghclient/factory.go), [internal/ghclient/wrapper.go](internal/ghclient/wrapper.go), [internal/ghclient/interface.go](internal/ghclient/interface.go).
- `CachingGitHubClientFactory` caches credentials per secret name and clients per organization (`cacheKey`); rate-limit state is shared per GitHub App ID.
- `SecretProviderFunc` now takes a `secretName string` parameter so any secret in the credentials namespace can be fetched on demand.
- `GetClient(ctx, cacheKey, app v1alpha1.GitHubAppConfig)` and `GetGitHubClientAndCheckRateLimit(...)` accept a `GitHubAppConfig` struct (containing `InstallationId` and `CredentialsSecretName`).
- When `spec.githubAppConfig` is set on an `Organization`, it takes precedence; otherwise the operator falls back to `spec.githubAppInstallationId` (deprecated) combined with the default secret name from `--app-credentials-secret-name`.
- Status conditions are set consistently via helpers; see [internal/conditions/conditions.go](internal/conditions/conditions.go). Finalizers gate deletion; see reconciler files.
- **Validation-only webhooks** enforce spec rules (e.g., organization custom properties, repository references); see [internal/webhook/v1alpha1/organization_webhook.go](internal/webhook/v1alpha1/organization_webhook.go) and [internal/webhook/v1alpha1/repository_webhook.go](internal/webhook/v1alpha1/repository_webhook.go). **Mutating webhooks have been removed**; labels are now applied in reconcilers. **Note**: Team CRD has no webhook currently.

Expand Down Expand Up @@ -164,7 +168,12 @@ Controllers implement continuous drift detection:

## Configuration & Secrets
- The manager reads flags for secret location and TLS; see [cmd/main.go](cmd/main.go) for `--app-credentials-secret-namespace` (default `github-controller`) and `--app-credentials-secret-name` (default `git-hubby-app-credentials`).
- Required secret keys: `app-id`, `private-key` (PEM RSA); parsed in [internal/ghclient/factory.go](internal/ghclient/factory.go). The GitHub App Installation ID is provided per-organization via `Organization.Spec.GitHubAppInstallationId`.
- Required secret keys: `app-id`, `private-key` (PEM RSA); parsed in [internal/ghclient/factory.go](internal/ghclient/factory.go).
- **Multiple GitHub Apps**: Each `Organization` CR can reference a different credentials Secret via `spec.githubAppConfig.credentialsSecretName`. All referenced secrets must reside in the namespace set by `--app-credentials-secret-namespace`. The operator fetches credentials lazily on first use and caches them per secret name.
- `spec.githubAppConfig` (preferred): specify both `installationId` and `credentialsSecretName`.
- `spec.githubAppInstallationId` (deprecated): uses the default secret from `--app-credentials-secret-name`; still supported for backward compatibility.
- If both are set, `githubAppConfig` takes precedence.
- **Secret rotation**: Updating a credentials Secret does **not** automatically invalidate the in-memory client cache. A pod restart is required to pick up rotated credentials.
- Metrics and webhook TLS can be configured via flags; HTTP/2 is disabled by default for security.
- **Log level**: Configurable via `LOG_LEVEL` environment variable (accepts `debug`, `info`, `warn`, `error`; case-insensitive). Overrides the `--zap-log-level` CLI flag. Can also be set in `.env` file.

Expand Down
77 changes: 0 additions & 77 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,9 @@ name: CI

on:
push:
pull_request:
# 'edited' is included so commitlint re-runs when PR title changes (used as squash commit message)
types: [opened, edited, synchronize, reopened]

permissions:
contents: read
pull-requests: write


jobs:
Expand Down Expand Up @@ -82,27 +78,6 @@ jobs:
fi
echo "Generated code is up to date."

commitlint:
name: Validate Commit Messages
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version

- name: Install commitlint
run: npm ci

- name: Lint commits
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose

test-e2e:
name: Test E2E
runs-on: ubuntu-latest
Expand All @@ -129,55 +104,3 @@ jobs:
run: |
go mod tidy
make test-e2e

helm-chart-reminder:
name: Helm Chart Update Reminder
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Check for Helm-relevant changes
id: changes
run: |
DIFF="$(git diff origin/${{ github.base_ref }}...HEAD)"
REASONS=""
if echo "$DIFF" | grep -qE '^\+.*\+kubebuilder:rbac'; then
REASONS="${REASONS}\n- RBAC markers (\`+kubebuilder:rbac\`) were added or modified → update RBAC template"
fi
if echo "$DIFF" | grep -qE '^\+.*\+kubebuilder:webhook'; then
REASONS="${REASONS}\n- Webhook markers (\`+kubebuilder:webhook\`) were added or modified → update webhook configuration template"
fi
if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q 'config/manager/manager.yaml'; then
REASONS="${REASONS}\n- \`config/manager/manager.yaml\` was modified → update deployment template (env vars, args, ports, volumes)"
fi
if [[ -n "$REASONS" ]]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
# Use delimiter for multiline output
echo "reasons<<EOF" >> "$GITHUB_OUTPUT"
echo -e "$REASONS" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi

- name: Comment on PR about Helm chart changes
if: steps.changes.outputs.changed == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
COMMENT_MARKER="<!-- helm-chart-reminder -->"
EXISTING=$(gh pr view ${{ github.event.pull_request.number }} --json comments --jq '[.comments[].body | select(contains("'"$COMMENT_MARKER"'"))] | length')
if [[ "$EXISTING" == "0" ]]; then
gh pr comment ${{ github.event.pull_request.number }} --body "${COMMENT_MARKER}
⚠️ **Helm Chart Update Required**

This PR contains changes that likely require a matching update in [git-hubby-helm](https://github.com/Interhyp/git-hubby-helm):

${{ steps.changes.outputs.reasons }}

After merging, run \`make manifests\` and compare the generated output in \`config/\` with the corresponding Helm chart templates."
fi
136 changes: 136 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
name: PR Checks
# Jobs in this workflow are enforced as required status checks via branch protection on main.
# This file is intentionally separate from ci.yml so these jobs only ever trigger on
# pull_request events — preventing the double-run / skipped-status-check race that occurs
# when a force-push fires both a 'push' event and a 'pull_request: synchronize' event
# within the same workflow file.

on:
pull_request:
# 'edited' is included so commitlint re-runs when PR title changes (used as squash commit message)
types: [opened, edited, synchronize, reopened]

permissions:
contents: read
pull-requests: write


jobs:
commitlint:
name: Validate Commit Messages
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version

- name: Install commitlint
run: npm ci

- name: Lint commits
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose

helm-chart-reminder:
name: Helm Chart Update Reminder
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Check for Helm-relevant changes
id: changes
run: |
# Restrict to *.go so the workflow files themselves don't self-match
# (the grep patterns appear verbatim in this YAML and would otherwise trigger false positives)
GO_DIFF="$(git diff origin/${{ github.base_ref }}...HEAD -- '*.go')"

echo "=== Changed .go files ==="
git diff --name-only origin/${{ github.base_ref }}...HEAD -- '*.go'
echo "=== All changed files ==="
git diff --name-only origin/${{ github.base_ref }}...HEAD

REASONS=""

RBAC_FILES="$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- '*.go' \
| while read -r f; do
if git diff origin/${{ github.base_ref }}...HEAD -- "$f" | grep -qE '^\+.*\+kubebuilder:rbac'; then
echo "$f"
fi
done)"
if [[ -n "$RBAC_FILES" ]]; then
echo "=== Files with new +kubebuilder:rbac markers ==="
echo "$RBAC_FILES"
FILE_LIST="$(echo "$RBAC_FILES" | sed 's/^/ - \`/' | sed 's/$/\`/')"
REASONS="${REASONS}\n- RBAC markers (\`+kubebuilder:rbac\`) were added or modified → update RBAC template\n${FILE_LIST}"
fi

WEBHOOK_FILES="$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- '*.go' \
| while read -r f; do
if git diff origin/${{ github.base_ref }}...HEAD -- "$f" | grep -qE '^\+.*\+kubebuilder:webhook'; then
echo "$f"
fi
done)"
if [[ -n "$WEBHOOK_FILES" ]]; then
echo "=== Files with new +kubebuilder:webhook markers ==="
echo "$WEBHOOK_FILES"
FILE_LIST="$(echo "$WEBHOOK_FILES" | sed 's/^/ - \`/' | sed 's/$/\`/')"
REASONS="${REASONS}\n- Webhook markers (\`+kubebuilder:webhook\`) were added or modified → update webhook configuration template\n${FILE_LIST}"
fi

if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q 'config/manager/manager.yaml'; then
echo "=== config/manager/manager.yaml was modified ==="
REASONS="${REASONS}\n- \`config/manager/manager.yaml\` was modified → update deployment template (env vars, args, ports, volumes)"
fi

if [[ -n "$REASONS" ]]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
# Use delimiter for multiline output
echo "reasons<<EOF" >> "$GITHUB_OUTPUT"
echo -e "$REASONS" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
else
echo "No Helm-relevant changes detected"
echo "changed=false" >> "$GITHUB_OUTPUT"
fi

- name: Comment on PR about Helm chart changes
env:
GH_TOKEN: ${{ github.token }}
run: |
COMMENT_MARKER="<!-- helm-chart-reminder -->"
echo "=== Looking up existing reminder comment ==="
EXISTING_ID=$(gh api \
"repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \
--jq '[.[] | select(.body | contains("'"$COMMENT_MARKER"'"))] | first | .id // empty')
echo "EXISTING_ID=${EXISTING_ID}"
echo "changed=${{ steps.changes.outputs.changed }}"
if [[ "${{ steps.changes.outputs.changed }}" == "true" ]]; then
if [[ -z "$EXISTING_ID" ]]; then
echo "=== Posting reminder comment ==="
gh pr comment ${{ github.event.pull_request.number }} --body "${COMMENT_MARKER}
⚠️ **Helm Chart Update Required**

This PR contains changes that likely require a matching update in [git-hubby-helm](https://github.com/Interhyp/git-hubby-helm):

${{ steps.changes.outputs.reasons }}

After merging, run \`make manifests\` and compare the generated output in \`config/\` with the corresponding Helm chart templates."
else
echo "=== Reminder comment already exists, skipping ==="
fi
else
if [[ -n "$EXISTING_ID" ]]; then
echo "=== Deleting stale reminder comment ==="
gh api -X DELETE "repos/${{ github.repository }}/issues/comments/${EXISTING_ID}"
else
echo "=== No reminder comment to delete ==="
fi
fi
55 changes: 50 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ A Kubernetes operator for managing GitHub organizations and repositories as code
### Key Features

- **Declarative GitHub Management**: Define organizations and repositories as Kubernetes resources
- **GitHub App Integration**: Secure authentication using GitHub App credentials
- **Multiple GitHub Apps**: Each organization can reference its own GitHub App credentials secret, enabling multi-tenant and multi-app setups
- **Multi-Plan Support**: Works with GitHub `free`, `team`, and `enterprise` plans — plan-gated features are automatically skipped when not available
- **Advanced Features**: Manage repository rulesets, webhooks, organization custom properties, and code security configurations
- **Rate Limit Awareness**: Built-in GitHub API rate limit handling with intelligent backoff
Expand Down Expand Up @@ -91,13 +91,17 @@ The log output format can be configured via:

### GitHub App Credentials

The operator requires a Kubernetes Secret with GitHub App credentials:
The operator authenticates with GitHub using GitHub App credentials stored in Kubernetes Secrets. Each organization can reference its own credentials secret, enabling multiple GitHub Apps across organizations.

#### Secret Format

Create one or more Secrets, each containing credentials for a GitHub App:

```yaml
apiVersion: v1
kind: Secret
metadata:
name: git-hubby-app-credentials
name: git-hubby-app-credentials # default secret name
namespace: github-controller
type: Opaque
stringData:
Expand All @@ -108,11 +112,52 @@ stringData:
-----END RSA PRIVATE KEY-----
```

**Secret location** is configurable via flags:
All credential secrets must reside in the same namespace, configured via:
- `--app-credentials-secret-namespace` (default: `github-controller`)

The default secret name is configured via:
- `--app-credentials-secret-name` (default: `git-hubby-app-credentials`)

**GitHub App Installation ID** is provided per-organization in the `Organization` resource spec.
#### Per-Organization App Configuration (recommended)

Use `spec.githubAppConfig` on the `Organization` resource to specify both the installation ID and which credentials secret to use:

```yaml
spec:
githubAppConfig:
installationId: 12345678
credentialsSecretName: my-org-app-credentials # references a Secret in the credentials namespace
```

This is the preferred approach and supports multiple GitHub Apps across different organizations.

#### Legacy: Single App via Installation ID (deprecated)

The older `spec.githubAppInstallationId` field is still supported for backward compatibility. When set alone, it uses the default credentials secret configured via `--app-credentials-secret-name`:

```yaml
spec:
githubAppInstallationId: 12345678 # deprecated; use githubAppConfig instead
```

If both `githubAppConfig` and `githubAppInstallationId` are set, `githubAppConfig` takes precedence.

#### Secret Rotation

Updating a credentials Secret in Kubernetes does **not** automatically invalidate the operator's in-memory client cache. To force the operator to pick up rotated credentials, restart the operator pod.

For automated rotation workflows, we recommend [Stakater Reloader](https://github.com/stakater/Reloader). Add the reload annotation to the operator `Deployment` and list the Secret(s) that should trigger a restart:

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: git-hubby-controller-manager
annotations:
secret.reloader.stakater.com/reload: "git-hubby-app-credentials,my-org-app-credentials"
```

When Reloader detects a change in any of the listed Secrets, it rolls the Deployment, causing the operator to restart and re-fetch fresh credentials.

## Architecture Highlights

Expand Down
Loading
Loading