diff --git a/.github/workflows/shared-e2e.yaml b/.github/workflows/shared-e2e.yaml new file mode 100644 index 0000000..27be8bf --- /dev/null +++ b/.github/workflows/shared-e2e.yaml @@ -0,0 +1,81 @@ +name: Shared E2E + +on: + workflow_call: + inputs: + runs-on: + description: "The runner to use for the job (must be a Linux runner; this workflow uses KinD and make)" + required: false + default: "ubuntu-latest" + type: string + greenhouse-ref: + description: "Ref (branch, tag, or SHA) of cloudoperators/greenhouse to deploy" + required: false + default: "main" + type: string + admin-k8s-version: + description: "Kubernetes node image tag for the admin KinD cluster (e.g. v1.31.0)" + required: true + type: string + remote-k8s-version: + description: "Kubernetes node image tag for the remote KinD cluster (e.g. v1.31.0)" + required: true + type: string + scenario: + description: "E2E scenario name passed to the composite action and make target" + required: true + type: string + test-target: + description: "The make target in the calling repo used to run e2e tests" + required: false + default: "e2e" + type: string + working-directory: + description: "Working directory for the make test target" + required: false + default: "." + type: string + environment: + description: "GitHub environment name to use for this job (optional)" + required: false + default: "" + type: string +jobs: + e2e: + runs-on: ${{ inputs.runs-on }} + environment: ${{ inputs.environment != '' && inputs.environment || null }} + permissions: + contents: read + steps: + - name: Validate runner + run: | + if [ "$RUNNER_OS" != "Linux" ]; then + echo "ERROR: this workflow requires a Linux runner (uses KinD and make); got: $RUNNER_OS" + exit 1 + fi + + - name: Run Greenhouse E2E composite action + uses: cloudoperators/common/workflows/e2e@main + with: + admin-k8s-version: ${{ inputs.admin-k8s-version }} + remote-k8s-version: ${{ inputs.remote-k8s-version }} + scenario: ${{ inputs.scenario }} + ref: ${{ inputs.greenhouse-ref }} + + - name: Checkout calling repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + path: caller + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + with: + go-version-file: caller/${{ inputs.working-directory != '.' && format('{0}/go.mod', inputs.working-directory) || 'go.mod' }} + cache: true + cache-dependency-path: caller/${{ inputs.working-directory != '.' && format('{0}/go.sum', inputs.working-directory) || 'go.sum' }} + + - name: Run e2e tests + working-directory: caller/${{ inputs.working-directory }} + env: + TEST_TARGET: ${{ inputs.test-target }} + run: make "$TEST_TARGET" diff --git a/.github/workflows/shared-go-build.yaml b/.github/workflows/shared-go-build.yaml new file mode 100644 index 0000000..dfe6002 --- /dev/null +++ b/.github/workflows/shared-go-build.yaml @@ -0,0 +1,140 @@ +name: Shared Go Build + +on: + workflow_call: + inputs: + runs-on: + description: "The runner to use for the job (must be a Linux or macOS runner; this workflow requires make)" + required: false + default: "ubuntu-latest" + type: string + working-directory: + description: "Working directory for the job" + required: false + default: "." + type: string + build-target: + description: "The make target to run for the build (e.g. build, build-all, docker-build)" + required: false + default: "build" + type: string + docker-build: + description: "Build and optionally push a Docker image after the Go build" + required: false + default: false + type: boolean + image-name: + description: "Full GHCR image name, e.g. ghcr.io/cloudoperators/myapp (must start with ghcr.io/ when push is true)" + required: false + default: "" + type: string + platforms: + description: "Comma-separated list of target platforms for the Docker image" + required: false + default: "linux/amd64,linux/arm64" + type: string + push: + description: "Push the built Docker image to the registry" + required: false + default: false + type: boolean + secrets: + registry-token: + description: "Token used to authenticate to the container registry when pushing" + required: false + +jobs: + build: + runs-on: ${{ inputs.runs-on }} + permissions: + contents: read + packages: ${{ inputs.push == true && 'write' || 'read' }} + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Validate inputs + env: + PUSH: ${{ inputs.push }} + DOCKER_BUILD: ${{ inputs.docker-build }} + run: | + case "$RUNNER_OS" in + Linux|macOS) ;; + *) echo "ERROR: this workflow requires a Linux or macOS runner (needs make); got: $RUNNER_OS"; exit 1 ;; + esac + if [ "$PUSH" = "true" ] && [ "$DOCKER_BUILD" != "true" ]; then + echo "ERROR: push=true requires docker-build=true" + exit 1 + fi + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + with: + go-version-file: ${{ format('{0}/go.mod', inputs.working-directory) }} + cache: true + cache-dependency-path: ${{ format('{0}/go.sum', inputs.working-directory) }} + + - name: Build + env: + BUILD_TARGET: ${{ inputs.build-target }} + run: make "$BUILD_TARGET" + + - name: Validate docker-build inputs + if: inputs.docker-build == true + env: + IMAGE_NAME: ${{ inputs.image-name }} + PUSH: ${{ inputs.push }} + run: | + if [ "$RUNNER_OS" != "Linux" ]; then + echo "ERROR: docker-build requires a Linux runner; got: $RUNNER_OS" + exit 1 + fi + if [ -z "$IMAGE_NAME" ]; then + echo "ERROR: image-name is required when docker-build is true" + exit 1 + fi + case "$IMAGE_NAME" in + ghcr.io/*) ;; + *) + if [ "$PUSH" = "true" ]; then + echo "ERROR: image-name must start with 'ghcr.io/' when push is true (got: '$IMAGE_NAME')" + exit 1 + fi + ;; + esac + + - name: Set up QEMU + if: inputs.docker-build == true + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff9c25c0e60b9eba63c # v3 + + - name: Set up Docker Buildx + if: inputs.docker-build == true + uses: docker/setup-buildx-action@b5730b4fe97e6f9f14b9d7bb5f0f0b9f75a3b6ca # v3 + + - name: Log in to container registry + if: inputs.docker-build == true && inputs.push == true + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.registry-token != '' && secrets.registry-token || secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + if: inputs.docker-build == true + id: meta + uses: docker/metadata-action@902fa8ec7d6ecbea8a63d9c1064e4b9e02685b72 # v5 + with: + images: ${{ inputs.image-name }} + + - name: Build and push Docker image + if: inputs.docker-build == true + uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6 + with: + context: ${{ inputs.working-directory }} + platforms: ${{ inputs.platforms }} + push: ${{ inputs.push }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/shared-go-lint.yaml b/.github/workflows/shared-go-lint.yaml new file mode 100644 index 0000000..5038bec --- /dev/null +++ b/.github/workflows/shared-go-lint.yaml @@ -0,0 +1,67 @@ +name: Shared Go Lint + +on: + workflow_call: + inputs: + runs-on: + description: "The runner to use for the job" + required: false + default: "ubuntu-latest" + type: string + golangci-lint-version: + description: "golangci-lint version to use" + required: false + default: "latest" + type: string + working-directory: + description: "Working directory for the job" + required: false + default: "." + type: string + enable-govulncheck: + description: "Run govulncheck in addition to golangci-lint" + required: false + default: false + type: boolean + govulncheck-version: + description: "Version of govulncheck to use (e.g. latest or v1.1.3)" + required: false + default: "latest" + type: string + +jobs: + lint: + runs-on: ${{ inputs.runs-on }} + permissions: + contents: read + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + with: + go-version-file: ${{ format('{0}/go.mod', inputs.working-directory) }} + cache: true + cache-dependency-path: ${{ format('{0}/go.sum', inputs.working-directory) }} + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + with: + version: ${{ inputs.golangci-lint-version }} + working-directory: ${{ inputs.working-directory }} + + - name: Run govulncheck + if: inputs.enable-govulncheck == true + shell: bash + env: + GOVULNCHECK_VERSION: ${{ inputs.govulncheck-version }} + run: | + if ! echo "$GOVULNCHECK_VERSION" | grep -qE '^(latest|v[0-9]+\.[0-9]+\.[0-9]+)$'; then + echo "ERROR: govulncheck-version must be 'latest' or a semver tag like v1.2.3 (got: '$GOVULNCHECK_VERSION')" + exit 1 + fi + go run "golang.org/x/vuln/cmd/govulncheck@${GOVULNCHECK_VERSION}" ./... diff --git a/.github/workflows/shared-go-test.yaml b/.github/workflows/shared-go-test.yaml new file mode 100644 index 0000000..72b7854 --- /dev/null +++ b/.github/workflows/shared-go-test.yaml @@ -0,0 +1,96 @@ +name: Shared Go Test + +on: + workflow_call: + inputs: + runs-on: + description: "The runner to use for the job (must be a Linux or macOS runner; this workflow requires make)" + required: false + default: "ubuntu-latest" + type: string + working-directory: + description: "Working directory for the job" + required: false + default: "." + type: string + test-target: + description: "The make target to run for tests" + required: false + default: "test" + type: string + upload-coverage: + description: "Upload coverage report as an artifact" + required: false + default: false + type: boolean + coverage-artifact-name: + description: "Name of the coverage artifact" + required: false + default: "code-coverage" + type: string + coverage-path: + description: "Path to the coverage output file" + required: false + default: "build/cover.out" + type: string + extra-env: + description: "Extra environment variables as newline-separated KEY=VALUE pairs" + required: false + default: "" + type: string + +jobs: + test: + runs-on: ${{ inputs.runs-on }} + permissions: + contents: read + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Validate runner + run: | + case "$RUNNER_OS" in + Linux|macOS) ;; + *) echo "ERROR: this workflow requires a Linux or macOS runner (needs make); got: $RUNNER_OS"; exit 1 ;; + esac + + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + with: + go-version-file: ${{ format('{0}/go.mod', inputs.working-directory) }} + cache: true + cache-dependency-path: ${{ format('{0}/go.sum', inputs.working-directory) }} + + - name: Download Go modules + run: go mod download + + - name: Set extra environment variables + if: inputs.extra-env != '' + env: + EXTRA_ENV: ${{ inputs.extra-env }} + run: | + while IFS= read -r line; do + [ -z "$line" ] && continue + if ! echo "$line" | grep -qE '^[A-Za-z_][A-Za-z0-9_]*='; then + echo "ERROR: invalid extra-env line (must be KEY=VALUE with a valid identifier key): '$line'" + exit 1 + fi + done <<< "$EXTRA_ENV" + printf '%s\n' "$EXTRA_ENV" >> "$GITHUB_ENV" + + - name: Run tests + env: + TEST_TARGET: ${{ inputs.test-target }} + run: make "$TEST_TARGET" + + - name: Upload coverage report + if: inputs.upload-coverage == true + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ inputs.coverage-artifact-name }} + path: ${{ inputs.working-directory != '.' && format('{0}/{1}', inputs.working-directory, inputs.coverage-path) || inputs.coverage-path }} + if-no-files-found: warn diff --git a/.github/workflows/shared-release.yaml b/.github/workflows/shared-release.yaml new file mode 100644 index 0000000..ee8be7a --- /dev/null +++ b/.github/workflows/shared-release.yaml @@ -0,0 +1,251 @@ +name: Shared Release + +on: + workflow_call: + inputs: + runs-on: + description: "The runner to use for the job (must be a Linux runner; this workflow uses GNU sed)" + required: false + default: "ubuntu-latest" + type: string + bump: + description: "Version bump type: patch, minor, or major" + required: true + type: string + chart-path: + description: "Relative path to Chart.yaml to update (e.g. charts/my-app/Chart.yaml). Leave empty to skip." + required: false + default: "" + type: string + bump-chart-app-version: + description: "Update the appVersion field in Chart.yaml to the new version" + required: false + default: true + type: boolean + makefile-path: + description: "Relative path to the Makefile containing the VERSION variable (e.g. Makefile). Leave empty ('') to skip Makefile version parsing and fall back to chart-path version." + required: false + default: "" + type: string + bump-make-version: + description: "Also update the IMG tag in the Makefile to the new version (the VERSION variable is always updated; this controls whether the IMG tag line is also rewritten)" + required: false + default: false + type: boolean + dispatch-greenhouse-extensions: + description: "Trigger a repository_dispatch event on cloudoperators/greenhouse-extensions after release" + required: false + default: false + type: boolean + plugin-name: + description: "Plugin name passed as payload when dispatching to greenhouse-extensions" + required: false + default: "" + type: string + environment: + description: "GitHub environment name to use for this job (e.g. release), for environment protection rules and scoped secrets" + required: false + default: "" + type: string + secrets: + release-token: + description: "PAT with contents:write permissions" + required: true + dispatch-app-id: + description: "GitHub App ID used to generate a token for dispatching to greenhouse-extensions" + required: false + dispatch-app-private-key: + description: "GitHub App private key used to generate a token for dispatching to greenhouse-extensions" + required: false + +jobs: + release: + runs-on: ${{ inputs.runs-on }} + environment: ${{ inputs.environment != '' && inputs.environment || null }} + concurrency: + group: release-${{ github.repository }} + cancel-in-progress: false + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + token: ${{ secrets.release-token }} + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 0 + + - name: Validate inputs + env: + MAKEFILE_PATH: ${{ inputs.makefile-path }} + CHART_PATH: ${{ inputs.chart-path }} + run: | + if [ "$RUNNER_OS" != "Linux" ]; then + echo "ERROR: this workflow requires a Linux runner (uses GNU sed); got: $RUNNER_OS" + exit 1 + fi + if [ -z "$MAKEFILE_PATH" ] && [ -z "$CHART_PATH" ]; then + echo "ERROR: at least one of makefile-path or chart-path is required for version computation" + exit 1 + fi + if [ -n "$MAKEFILE_PATH" ] && [ ! -f "$MAKEFILE_PATH" ]; then + echo "ERROR: makefile-path '$MAKEFILE_PATH' does not exist" + exit 1 + fi + if [ -n "$CHART_PATH" ] && [ ! -f "$CHART_PATH" ]; then + echo "ERROR: chart-path '$CHART_PATH' does not exist" + exit 1 + fi + + - name: Compute new version + id: version + env: + MAKEFILE_PATH: ${{ inputs.makefile-path }} + CHART_PATH: ${{ inputs.chart-path }} + BUMP: ${{ inputs.bump }} + run: | + CURRENT_VERSION="" + if [ -n "$MAKEFILE_PATH" ]; then + CURRENT_VERSION=$(grep -E '^VERSION[[:space:]]*[:?]?=' "$MAKEFILE_PATH" | head -1 | sed 's/.*=[[:space:]]*//' | sed 's/[[:space:]]*#.*//' | tr -d '[:space:]' || true) + fi + if ! echo "$CURRENT_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + if [ -n "$CHART_PATH" ]; then + CURRENT_VERSION=$(grep -E '^version:[[:space:]]*' "$CHART_PATH" | head -1 | sed 's/^version:[[:space:]]*//' | tr -d '[:space:]"' || true) + echo "Falling back to Chart.yaml version: $CURRENT_VERSION" + fi + fi + echo "Current version: $CURRENT_VERSION" + + if ! echo "$CURRENT_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "ERROR: could not parse version from makefile-path or chart-path (got: '$CURRENT_VERSION')" + exit 1 + fi + + MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1) + MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2) + PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3) + + case "$BUMP" in + major) + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + ;; + minor) + MINOR=$((MINOR + 1)) + PATCH=0 + ;; + patch) + PATCH=$((PATCH + 1)) + ;; + *) + echo "ERROR: Invalid bump type: $BUMP" + exit 1 + ;; + esac + + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" + echo "New version: $NEW_VERSION" + echo "current=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" + echo "new=$NEW_VERSION" >> "$GITHUB_OUTPUT" + + - name: Update Makefile VERSION + if: inputs.makefile-path != '' + env: + NEW_VERSION: ${{ steps.version.outputs.new }} + MAKEFILE_PATH: ${{ inputs.makefile-path }} + run: | + sed -i -E "s/^(VERSION[[:space:]]*[:?]?=)[[:space:]]*.*/\1 ${NEW_VERSION}/" "$MAKEFILE_PATH" + + - name: Update Makefile IMG tag + if: inputs.bump-make-version == true && inputs.makefile-path != '' + env: + NEW_VERSION: ${{ steps.version.outputs.new }} + MAKEFILE_PATH: ${{ inputs.makefile-path }} + run: | + sed -i -E "s|^(IMG[[:space:]]*[:?]?=.*:).*|\1${NEW_VERSION}|" "$MAKEFILE_PATH" + + - name: Update Chart.yaml version + if: inputs.chart-path != '' + env: + NEW_VERSION: ${{ steps.version.outputs.new }} + CHART_PATH: ${{ inputs.chart-path }} + run: | + sed -i "s/^version:.*/version: ${NEW_VERSION}/" "$CHART_PATH" + + - name: Update Chart.yaml appVersion + if: inputs.chart-path != '' && inputs.bump-chart-app-version == true + env: + NEW_VERSION: ${{ steps.version.outputs.new }} + CHART_PATH: ${{ inputs.chart-path }} + run: | + sed -i "s/^appVersion:.*/appVersion: \"${NEW_VERSION}\"/" "$CHART_PATH" + + - name: Commit version bump and tag + env: + MAKEFILE_PATH: ${{ inputs.makefile-path }} + CHART_PATH: ${{ inputs.chart-path }} + NEW_VERSION: ${{ steps.version.outputs.new }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + if [ -n "$MAKEFILE_PATH" ]; then git add "$MAKEFILE_PATH"; fi + if [ -n "$CHART_PATH" ]; then git add "$CHART_PATH"; fi + git commit -m "chore: bump version to ${NEW_VERSION}" + git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}" + git push origin "$DEFAULT_BRANCH" --follow-tags + + - name: Create GitHub release + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + github-token: ${{ secrets.release-token }} + script: | + await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: 'v${{ steps.version.outputs.new }}', + name: 'v${{ steps.version.outputs.new }}', + generate_release_notes: true, + draft: false, + prerelease: false + }); + + - name: Validate dispatch inputs + if: inputs.dispatch-greenhouse-extensions == true + env: + PLUGIN_NAME: ${{ inputs.plugin-name }} + DISPATCH_APP_ID: ${{ secrets.dispatch-app-id }} + DISPATCH_APP_KEY: ${{ secrets.dispatch-app-private-key }} + run: | + if [ -z "$DISPATCH_APP_ID" ] || [ -z "$DISPATCH_APP_KEY" ]; then + echo "ERROR: dispatch-app-id and dispatch-app-private-key secrets are required when dispatch-greenhouse-extensions is true" + exit 1 + fi + if [ -z "$PLUGIN_NAME" ]; then + echo "ERROR: plugin-name input is required when dispatch-greenhouse-extensions is true" + exit 1 + fi + if ! echo "$PLUGIN_NAME" | grep -qE '^[a-zA-Z0-9_-]+$'; then + echo "ERROR: plugin-name must contain only alphanumeric characters, hyphens, or underscores (got: '$PLUGIN_NAME')" + exit 1 + fi + + - name: Generate GitHub App token for dispatch + if: inputs.dispatch-greenhouse-extensions == true + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.dispatch-app-id }} + private-key: ${{ secrets.dispatch-app-private-key }} + repositories: greenhouse-extensions + permission-contents: write + + - name: Dispatch to greenhouse-extensions + if: inputs.dispatch-greenhouse-extensions == true + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4 + with: + token: ${{ steps.app-token.outputs.token }} + repository: cloudoperators/greenhouse-extensions + event-type: deployment-params + client-payload: '{"plugin":"${{ inputs.plugin-name }}","version":"${{ steps.version.outputs.new }}"}' diff --git a/workflows/e2e/action.yaml b/workflows/e2e/action.yaml index f94e08f..b3e1da4 100644 --- a/workflows/e2e/action.yaml +++ b/workflows/e2e/action.yaml @@ -104,8 +104,8 @@ runs: GREENHOUSE_ADMIN_KUBECONFIG: ${{ steps.config.outputs.admin_config }} GREENHOUSE_REMOTE_KUBECONFIG: ${{ steps.config.outputs.remote_config }} GREENHOUSE_REMOTE_INT_KUBECONFIG: ${{ steps.config.outputs.remote_int_config }} - CONTROLLER_LOGS_PATH: ${{github.workspace}}/bin/${{inputs.scenario}}-${{inputs.k8s-version}}.txt - E2E_REPORT_PATH: ${{github.workspace}}/bin/${{inputs.scenario}}-${{matrix.k8s-version}}.json + CONTROLLER_LOGS_PATH: ${{github.workspace}}/bin/${{inputs.scenario}}-${{inputs.remote-k8s-version}}.txt + E2E_REPORT_PATH: ${{github.workspace}}/bin/${{inputs.scenario}}-${{inputs.remote-k8s-version}}.json run: | - echo "result=$CONTROLLER_LOGS_PATH" >> $GITHUB_OUTPUT + echo "result=$E2E_REPORT_PATH" >> $GITHUB_OUTPUT make e2e