diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..59c472c --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "local>panicboat/.github" + ] +} diff --git a/.github/workflows/ci-gatekeeper.yaml b/.github/workflows/ci-gatekeeper.yaml new file mode 100644 index 0000000..da7d066 --- /dev/null +++ b/.github/workflows/ci-gatekeeper.yaml @@ -0,0 +1,14 @@ +# Waits for every other workflow on a pull request so that one required check gates the merge. +name: 'CI Gatekeeper' + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: + - '**' + +jobs: + gatekeeper: + permissions: + actions: read + uses: ./.github/workflows/reusable--ci-gatekeeper.yaml diff --git a/.github/workflows/lint-actions.yml b/.github/workflows/lint-actions.yml new file mode 100644 index 0000000..e41c2ca --- /dev/null +++ b/.github/workflows/lint-actions.yml @@ -0,0 +1,13 @@ +# Checks that every action is pinned to a full commit SHA. +name: Lint GitHub Actions + +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + permissions: + contents: read + uses: ./.github/workflows/reusable--lint-actions.yaml diff --git a/.github/workflows/release--please.yaml b/.github/workflows/release--please.yaml new file mode 100644 index 0000000..e2f4fbb --- /dev/null +++ b/.github/workflows/release--please.yaml @@ -0,0 +1,21 @@ +# Runs release-please for this repository to open release pull requests and publish releases. +name: 'Release - Please' + +on: + push: + branches: [main] + workflow_dispatch: {} + +concurrency: + group: release-please + cancel-in-progress: false + +jobs: + release-please: + permissions: + contents: read + uses: ./.github/workflows/reusable--release--please.yaml + with: + app-id: ${{ vars.APP_ID }} + secrets: + private-key: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.github/workflows/reusable--ci-gatekeeper.yaml b/.github/workflows/reusable--ci-gatekeeper.yaml new file mode 100644 index 0000000..9d297da --- /dev/null +++ b/.github/workflows/reusable--ci-gatekeeper.yaml @@ -0,0 +1,23 @@ +# Waits for every other workflow on a pull request so that one required check gates the merge. +name: 'Reusable - CI Gatekeeper' + +on: + workflow_call: + inputs: + exclude-workflow-names: + required: false + type: string + default: '' + description: 'Workflow names the gatekeeper does not wait for, one per line' + +jobs: + ci-gatekeeper: + name: CI Gatekeeper + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Wait for other workflows + uses: int128/wait-for-workflows-action@b320164b37276db89a12829914d6eb6ef4973d2b # v1.108.0 + with: + max-timeout-seconds: 1800 + exclude-workflow-names: ${{ inputs.exclude-workflow-names }} diff --git a/.github/workflows/reusable--cleanup-container-image.yaml b/.github/workflows/reusable--cleanup-container-image.yaml new file mode 100644 index 0000000..6c46c93 --- /dev/null +++ b/.github/workflows/reusable--cleanup-container-image.yaml @@ -0,0 +1,70 @@ +# Deletes stale pr- and sha- tags and untagged images of the given service containers in ghcr.io. +name: 'Reusable - Cleanup Container Images' + +on: + workflow_call: + inputs: + services: + required: true + type: string + description: 'JSON array of service names whose containers are cleaned (e.g. ["monolith","frontend"])' + +jobs: + cleanup-container: + name: 'Cleanup ${{ matrix.service }}' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + matrix: + service: ${{ fromJson(inputs.services) }} + fail-fast: false + steps: + - name: Clean old PR tags + uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + package: ${{ github.event.repository.name }}/${{ matrix.service }} + delete-tags: ^pr- + older-than: 1 days + exclude-tags: ^(latest|v\d+\.\d+\.\d+.*)$ + use-regex: true + validate: true + + - name: Clean SHA tags + uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + package: ${{ github.event.repository.name }}/${{ matrix.service }} + delete-tags: ^sha- + exclude-tags: ^(latest|v\d+\.\d+\.\d+.*)$ + use-regex: true + validate: true + + - name: Clean untagged images + uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + package: ${{ github.event.repository.name }}/${{ matrix.service }} + keep-n-untagged: 0 + delete-orphaned-images: true + delete-ghost-images: true + delete-partial-images: true + exclude-tags: ^(latest|v\d+\.\d+\.\d+.*)$ + use-regex: true + validate: true + + summary: + name: 'Cleanup Summary' + needs: cleanup-container + if: always() + runs-on: ubuntu-latest + steps: + - name: Summary + env: + SERVICES: ${{ inputs.services }} + run: | + echo "๐Ÿ“Š Cleanup Summary" + echo "Services: ${SERVICES}" + echo "Cleanup job status: ${{ needs.cleanup-container.result }}" diff --git a/.github/workflows/reusable--container-builder.yaml b/.github/workflows/reusable--container-builder.yaml new file mode 100644 index 0000000..3b5be97 --- /dev/null +++ b/.github/workflows/reusable--container-builder.yaml @@ -0,0 +1,93 @@ +# Builds the container image of one service and pushes it to ghcr.io. +name: Reusable Container Builder + +on: + workflow_call: + inputs: + image-name: + required: true + type: string + description: 'Name of the image (e.g. service-name)' + working-directory: + required: true + type: string + description: 'Directory containing the Dockerfile' + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + semver-tag: + required: false + type: string + default: '' + description: 'Optional semver tag (e.g. v0.1.0) to add to the built image. Only set when called from release event flow.' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + build-and-push: + if: inputs.image-name != '' + runs-on: ubuntu-26.04-arm + + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ secrets.private-key }} + owner: ${{ github.repository_owner }} + + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Check for Dockerfile + id: dockerfile + run: | + if [ -f "${{ inputs.working-directory }}/Dockerfile" ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to the Container registry + if: steps.dockerfile.outputs.exists == 'true' + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + if: steps.dockerfile.outputs.exists == 'true' + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - name: Extract metadata (tags, labels) for Docker + id: meta + if: steps.dockerfile.outputs.exists == 'true' + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ghcr.io/${{ github.repository }}/${{ inputs.image-name }} + tags: | + type=sha + type=ref,event=pr + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=${{ github.actor }} + type=semver,pattern={{raw}},value=${{ inputs.semver-tag }},enable=${{ inputs.semver-tag != '' }} + + - name: Build and push Docker image + if: steps.dockerfile.outputs.exists == 'true' + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + platforms: linux/arm64 + context: ${{ inputs.working-directory }} + file: ${{ inputs.working-directory }}/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/reusable--docs-sync-check.yaml b/.github/workflows/reusable--docs-sync-check.yaml new file mode 100644 index 0000000..3024826 --- /dev/null +++ b/.github/workflows/reusable--docs-sync-check.yaml @@ -0,0 +1,222 @@ +# Judges with Claude whether a pull request keeps the docs in sync and reports the verdict as the Docs Sync Check commit status. +name: 'Reusable - Docs Sync Check' + +on: + workflow_call: + inputs: + scope-pattern: + required: true + type: string + description: 'Extended regex matching the changed paths whose docs are checked (e.g. ^dystopia/)' + prompt-path: + required: true + type: string + description: 'Path of the prompt file in the calling repository (e.g. .github/prompts/docs-sync-check.md)' + +jobs: + scope: + name: 'Scope' + # Fork pull requests only get a read-only token, so the status could not be written. + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: write + statuses: write + outputs: + mode: ${{ steps.decide.outputs.mode }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Checkout shared workflows + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared-workflows + persist-credentials: false + + - name: Test decide script + run: bash .shared-workflows/scripts/docs-sync-check/decide.test.sh + + - name: Decide + id: decide + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + SCOPE_PATTERN: ${{ inputs.scope-pattern }} + EVENT_ACTION: ${{ github.event.action }} + EVENT_LABEL: ${{ github.event.label.name }} + APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'docs-check-approved') }} + AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} + BODY_OR_BASE_CHANGED: ${{ (github.event.changes.body || github.event.changes.base) && 'true' || 'false' }} + run: | + approved="$APPROVED" + prior_state="" + if [ "$EVENT_ACTION" = "labeled" ] && [ "$EVENT_LABEL" = "docs-check-approved" ]; then + # The payload labels are stale when this run starts after another run already removed the label. + approved="$(gh api "repos/$REPOSITORY/issues/$PR_NUMBER/labels?per_page=100" --jq 'any(.[]; .name == "docs-check-approved")')" + prior_state="$(gh api "repos/$REPOSITORY/commits/$HEAD_SHA/status?per_page=100" --jq '[.statuses[] | select(.context == "Docs Sync Check") | .state][0] // ""')" + fi + gh api --paginate "repos/$REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[] | .filename, (.previous_filename // empty)' | APPROVED="$approved" PRIOR_STATE="$prior_state" bash .shared-workflows/scripts/docs-sync-check/decide.sh >> "$GITHUB_OUTPUT" + + - name: Remove approval label + if: steps.decide.outputs.remove_label == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + if ! output="$(gh api -X DELETE "repos/${REPOSITORY}/issues/${PR_NUMBER}/labels/docs-check-approved" 2>&1)"; then + # SILENT: a 404 means someone already removed the label, which is the state this step wants. + if grep -q 'HTTP 404' <<<"$output"; then exit 0; fi + echo "$output" >&2 + exit 1 + fi + + - name: Write status + if: steps.decide.outputs.mode != 'ignore' + env: + GH_TOKEN: ${{ github.token }} + MODE: ${{ steps.decide.outputs.mode }} + REPOSITORY: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + case "$MODE" in + skip-bot) state=success; description='bot ใฎ PR ใฏๅˆคๅฎšใ—ใพใ›ใ‚“' ;; + skip-unchanged) state=success; description='ๅฏพ่ฑกใฎใƒ‘ใ‚นใซๅค‰ๆ›ดใŒใ‚ใ‚Šใพใ›ใ‚“' ;; + approved) state=success; description='docs-check-approved ใƒฉใƒ™ใƒซใงๆ‰ฟ่ชใ•ใ‚Œใฆใ„ใพใ™' ;; + judge) state=pending; description='Claude ใŒๅˆคๅฎšใ—ใฆใ„ใพใ™' ;; + *) echo "unknown mode: $MODE" >&2; exit 1 ;; + esac + gh api "repos/${REPOSITORY}/statuses/${HEAD_SHA}" \ + -f state="$state" \ + -f context='Docs Sync Check' \ + -f description="$description" \ + -f target_url="$RUN_URL" + + - name: Write failure status + if: ${{ failure() && steps.decide.outputs.mode != 'ignore' }} + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + gh api "repos/${REPOSITORY}/statuses/${HEAD_SHA}" \ + -f state=failure \ + -f context='Docs Sync Check' \ + -f description='ๅˆคๅฎšใงใใพใ›ใ‚“ใงใ—ใŸใ€‚ๅ†ๅฎŸ่กŒใ™ใ‚‹ใ‹ใ€็ขบใ‹ใ‚ใฆ docs-check-approved ใ‚’ไป˜ใ‘ใฆใใ ใ•ใ„' \ + -f target_url="$RUN_URL" + + judge: + name: 'Judge' + needs: scope + if: needs.scope.outputs.mode == 'judge' + runs-on: ubuntu-latest + timeout-minutes: 30 + # Environment-scoped OIDC sub keeps a prompt-injected run from assuming the plan roles, which trust the pull_request sub. + environment: docs-sync-check + permissions: + contents: read + pull-requests: read + id-token: write + outputs: + passed: ${{ steps.claude.outputs.structured_output && fromJSON(steps.claude.outputs.structured_output).passed || '' }} + body: ${{ steps.claude.outputs.structured_output && fromJSON(steps.claude.outputs.structured_output).body || '' }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 + with: + role-to-assume: arn:aws:iam::559744160976:role/github-oidc-auth-master-github-actions-bedrock-role + aws-region: us-east-1 + + - name: Judge with Claude + id: claude + uses: anthropics/claude-code-action@16f9f71ab9c490faf367f94dd294897edc25be33 # v1.0.261 + env: + # The Bedrock role only allows Sonnet 4.6, so background tasks must not fall back to Haiku. + ANTHROPIC_DEFAULT_HAIKU_MODEL: global.anthropic.claude-sonnet-4-6 + with: + use_bedrock: 'true' + # Read-only token; without it the action exchanges OIDC for the Claude App's write token. + github_token: ${{ github.token }} + prompt: | + Read `${{ inputs.prompt-path }}` and follow it. + Pull request number: ${{ github.event.pull_request.number }} + claude_args: | + --model global.anthropic.claude-sonnet-4-6 + --json-schema '{"type":"object","properties":{"passed":{"type":"boolean"},"body":{"type":"string","minLength":1,"maxLength":60000}},"required":["passed","body"]}' + --allowedTools "Read,Glob,Grep,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*)" + + - name: Upload execution log + if: ${{ !cancelled() && steps.claude.outputs.execution_file != '' }} + continue-on-error: true + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: docs-sync-check-execution + path: ${{ steps.claude.outputs.execution_file }} + retention-days: 7 + + report: + name: 'Report' + needs: [scope, judge] + # Runs after a failed judge too so Scope's pending status gets a verdict; a cancelled run writes nothing and leaves it pending. + if: ${{ !cancelled() && needs.scope.outputs.mode == 'judge' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + pull-requests: write + statuses: write + steps: + - name: Upsert judge comment + if: needs.judge.outputs.body != '' + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 + with: + message: ${{ needs.judge.outputs.body }} + comment-tag: docs-sync-check + mode: upsert + pr-number: ${{ github.event.pull_request.number }} + + - name: Write status + env: + GH_TOKEN: ${{ github.token }} + JUDGE_RESULT: ${{ needs.judge.result }} + PASSED: ${{ needs.judge.outputs.passed }} + HAS_BODY: ${{ needs.judge.outputs.body != '' }} + REPOSITORY: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + if [ "$JUDGE_RESULT" != "success" ] || [ "$HAS_BODY" != "true" ]; then + state=failure + description='ๅˆคๅฎšใงใใพใ›ใ‚“ใงใ—ใŸใ€‚ๅ†ๅฎŸ่กŒใ™ใ‚‹ใ‹ใ€็ขบใ‹ใ‚ใฆ docs-check-approved ใ‚’ไป˜ใ‘ใฆใใ ใ•ใ„' + elif [ "$PASSED" = "true" ]; then + state=success + description='ๆ–‡ๆ›ธใจ้ฃŸใ„้•ใ„ใฏใ‚ใ‚Šใพใ›ใ‚“' + else + state=failure + description='ๆ–‡ๆ›ธใจใฎ้ฃŸใ„้•ใ„ใŒใ‚ใ‚Šใพใ™ใ€‚PR ใฎใ‚ณใƒกใƒณใƒˆใ‚’่ฆ‹ใฆใใ ใ•ใ„' + fi + gh api "repos/${REPOSITORY}/statuses/${HEAD_SHA}" \ + -f state="$state" \ + -f context='Docs Sync Check' \ + -f description="$description" \ + -f target_url="$RUN_URL" diff --git a/.github/workflows/reusable--kubernetes-builder.yaml b/.github/workflows/reusable--kubernetes-builder.yaml new file mode 100644 index 0000000..b0a4717 --- /dev/null +++ b/.github/workflows/reusable--kubernetes-builder.yaml @@ -0,0 +1,106 @@ +# Posts the kustomize diff of one kustomization directory as a pull request comment. +name: 'Reusable - Kubernetes Builder' + +on: + workflow_call: + inputs: + service-name: + required: true + type: string + description: 'Service name for identification' + environment: + required: true + type: string + description: 'Environment name (develop, staging, production, etc.)' + kustomization-directory: + required: true + type: string + description: 'Directory containing the kustomization.yaml to diff (skips the job when empty)' + ref: + required: false + type: string + default: '' + description: 'Git ref to check out for the head manifests (empty uses the default ref of the triggering event)' + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + kustomize-diff: + name: 'Kustomize Diff' + if: inputs.kustomization-directory != '' + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ secrets.private-key }} + owner: ${{ github.repository_owner }} + + - name: Get PR information + id: pr-info + uses: jwalton/gh-find-current-pr@f3d61b485d2801773f7a07b2aaa3306bd8f8e653 # v1.3.5 + with: + github-token: ${{ steps.app-token.outputs.token }} + state: all + continue-on-error: true + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + token: ${{ steps.app-token.outputs.token }} + ref: ${{ inputs.ref }} + + - name: Checkout base ref + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: base + token: ${{ steps.app-token.outputs.token }} + + - name: Build head manifests + id: kustomize-head + uses: int128/kustomize-action@19a766fc2f43be39d1436d59555033be8aca07db # v1.196.0 + with: + kustomization: ${{ inputs.kustomization-directory }}/kustomization.yaml + write-individual-files: true + + - name: Build base manifests + id: kustomize-base + uses: int128/kustomize-action@19a766fc2f43be39d1436d59555033be8aca07db # v1.196.0 + with: + base-directory: base + kustomization: ${{ inputs.kustomization-directory }}/kustomization.yaml + write-individual-files: true + + - name: Diff manifests + id: diff + uses: int128/diff-action@274034d712aef611adcbd41e5a647f43e4edd5a5 # v2.41.0 + with: + base: ${{ steps.kustomize-base.outputs.directory }} + head: ${{ steps.kustomize-head.outputs.directory }} + + - name: Comment PR + if: steps.pr-info.outputs.number != '' + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 + with: + message: | + ## Kustomize Diff + + **Service**: `${{ inputs.service-name }}` + **Environment**: `${{ inputs.environment }}` + + ${{ steps.diff.outputs.comment-body || 'No changes' }} + comment-tag: "kubernetes-${{ inputs.service-name }}-${{ inputs.environment }}" + mode: upsert + pr-number: ${{ steps.pr-info.outputs.number }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + reactions: ${{ steps.diff.outputs.has-diff == 'true' && 'rocket' || '' }} + continue-on-error: true diff --git a/.github/workflows/reusable--kubernetes-hydrator.yaml b/.github/workflows/reusable--kubernetes-hydrator.yaml new file mode 100644 index 0000000..537adbf --- /dev/null +++ b/.github/workflows/reusable--kubernetes-hydrator.yaml @@ -0,0 +1,130 @@ +# Renders the given components into kubernetes/manifests and commits the result to the pull request branch. +name: 'Reusable - Kubernetes Hydrator' + +on: + workflow_call: + inputs: + environment: + required: true + type: string + description: 'Target environment for hydration (e.g., local)' + services: + required: true + type: string + description: 'JSON array of service names to hydrate (e.g. ["coredns","cilium"])' + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +concurrency: + group: kubernetes-hydrate-${{ github.event.pull_request.number || github.ref }}-${{ inputs.environment }} + cancel-in-progress: false + +jobs: + hydrate: + name: 'Hydrate' + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ secrets.private-key }} + owner: ${{ github.repository_owner }} + + - name: Get PR information + id: pr-info + uses: jwalton/gh-find-current-pr@f3d61b485d2801773f7a07b2aaa3306bd8f8e653 # v1.3.5 + with: + github-token: ${{ steps.app-token.outputs.token }} + state: all + continue-on-error: true + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + token: ${{ steps.app-token.outputs.token }} + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Checkout shared workflows + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared-workflows + persist-credentials: false + + - name: Setup aqua + uses: aquaproj/aqua-installer@82d0e83878a8657c4a6f2ff1755d3bfbfc67f329 # v4.1.0 + with: + aqua_version: v2.64.0 + + - name: Hydrate changed components + env: + SERVICES_JSON: ${{ inputs.services }} + run: | + set -euo pipefail + for svc in $(echo "$SERVICES_JSON" | jq -r '.[]'); do + bash .shared-workflows/scripts/kubernetes-hydrate/hydrate-component.sh "$svc" "${{ inputs.environment }}" + done + + - name: Hydrate index + run: bash .shared-workflows/scripts/kubernetes-hydrate/hydrate-index.sh "${{ inputs.environment }}" + + - name: Commit and push hydrated manifests + id: auto-commit + uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0 + with: + commit_message: 'chore(kubernetes/manifests/${{ inputs.environment }}): hydrate manifests' + commit_options: '--signoff' + file_pattern: 'kubernetes/manifests/${{ inputs.environment }}/' + commit_user_name: 'github-actions[bot]' + commit_user_email: 'github-actions[bot]@users.noreply.github.com' + + - name: Compute index diff + id: index-diff + if: steps.pr-info.outputs.number != '' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + ENV: ${{ inputs.environment }} + run: | + set -euo pipefail + diff=$(git diff "$BASE_SHA" HEAD -- \ + "kubernetes/manifests/$ENV/kustomization.yaml" \ + "kubernetes/manifests/$ENV/00-namespaces/" || true) + { + echo 'diff<> "$GITHUB_OUTPUT" + if [ -n "$diff" ]; then + echo "has-diff=true" >> "$GITHUB_OUTPUT" + else + echo "has-diff=false" >> "$GITHUB_OUTPUT" + fi + + - name: Comment index diff + if: steps.index-diff.outputs.has-diff == 'true' && steps.pr-info.outputs.number != '' + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 + with: + message: | + ## Kubernetes Index Diff + + **Environment**: `${{ inputs.environment }}` + + ```diff + ${{ steps.index-diff.outputs.diff }} + ``` + comment-tag: 'kubernetes-index-${{ inputs.environment }}' + mode: upsert + pr-number: ${{ steps.pr-info.outputs.number }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + continue-on-error: true diff --git a/.github/workflows/reusable--label-base-deploy--dispatcher.yaml b/.github/workflows/reusable--label-base-deploy--dispatcher.yaml new file mode 100644 index 0000000..737db25 --- /dev/null +++ b/.github/workflows/reusable--label-base-deploy--dispatcher.yaml @@ -0,0 +1,49 @@ +# Adds deploy:{service} labels to a pull request from the stack paths it changes. +name: 'Reusable - Label Base Deploy - Dispatcher' + +on: + workflow_call: + inputs: + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + config-path: + required: false + type: string + default: 'workflow-config.yaml' + description: 'Path to the workflow config that maps stack paths to services' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + dispatch-labels: + name: 'Dispatch Labels' + if: ${{ !startsWith(github.head_ref, 'release-please--') }} + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ secrets.private-key }} + owner: ${{ github.repository_owner }} + + - name: Get PR information + id: pr-info + uses: jwalton/gh-find-current-pr@f3d61b485d2801773f7a07b2aaa3306bd8f8e653 # v1.3.5 + with: + github-token: ${{ steps.app-token.outputs.token }} + state: all + continue-on-error: true + + - name: Dispatch Labels + uses: panicboat/deploy-actions/label-dispatcher@1ab520e5ef33b8d744982762ffc4e08009a34744 # v2.0.1 + with: + pr-number: ${{ steps.pr-info.outputs.number }} + repository: ${{ github.repository }} + github-token: ${{ steps.app-token.outputs.token }} + config-path: ${{ inputs.config-path }} diff --git a/.github/workflows/reusable--label-base-deploy--resolver.yaml b/.github/workflows/reusable--label-base-deploy--resolver.yaml new file mode 100644 index 0000000..4a2a98d --- /dev/null +++ b/.github/workflows/reusable--label-base-deploy--resolver.yaml @@ -0,0 +1,207 @@ +# Resolves deploy labels into targets and runs each stack the caller's workflow config enables: terragrunt plan/apply, container builds, and kustomize/helmfile diffs on pull requests. +name: 'Reusable - Label Base Deploy - Resolver' + +on: + workflow_call: + inputs: + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + config-path: + required: false + type: string + default: 'workflow-config.yaml' + description: 'Path to the workflow config that defines the stacks and services' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + resolve: + name: 'Resolve Targets' + runs-on: ubuntu-latest + outputs: + targets: ${{ steps.resolver.outputs.targets }} + has-targets: ${{ steps.resolver.outputs.has-targets }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Checkout shared workflows + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared-workflows + persist-credentials: false + + - name: Validate apply_trigger + run: bash .shared-workflows/scripts/terragrunt-release/validate-apply-trigger.sh "${{ inputs.config-path }}" + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ secrets.private-key }} + owner: ${{ github.repository_owner }} + + - name: Get PR information + id: pr-info + uses: jwalton/gh-find-current-pr@f3d61b485d2801773f7a07b2aaa3306bd8f8e653 # v1.3.5 + with: + github-token: ${{ steps.app-token.outputs.token }} + state: all + continue-on-error: true + + - name: Label Resolver + id: resolver + uses: panicboat/deploy-actions/label-resolver@1ab520e5ef33b8d744982762ffc4e08009a34744 # v2.0.1 + with: + repository: ${{ github.repository }} + pr-number: ${{ steps.pr-info.outputs.number }} + github-token: ${{ steps.app-token.outputs.token }} + config-path: ${{ inputs.config-path }} + + deploy-terragrunt: + name: 'Deploy Terragrunt (${{ matrix.target.service }}:${{ matrix.target.environment }})' + needs: resolve + if: | + needs.resolve.outputs.has-targets == 'true' && + contains(needs.resolve.outputs.targets, '"stack":"terragrunt"') + strategy: + matrix: + target: ${{ fromJson(needs.resolve.outputs.targets) }} + fail-fast: false + uses: $/.github/workflows/reusable--terragrunt-runner.yaml + with: + service-name: ${{ matrix.target.service }} + environment: ${{ matrix.target.environment }} + action-type: ${{ matrix.target.stack == 'terragrunt' && (github.event_name == 'pull_request' && 'plan' || (matrix.target.apply_trigger == 'merge' && 'apply' || '')) || '' }} + iam-role: ${{ github.event_name == 'pull_request' && matrix.target.iam_role_plan || matrix.target.iam_role_apply }} + aws-region: ${{ matrix.target.aws_region }} + working-directory: ${{ matrix.target.working_directory }} + app-id: ${{ inputs.app-id }} + secrets: + private-key: ${{ secrets.private-key }} + + deploy-container: + name: 'Deploy Container (${{ matrix.target.service }})' + needs: resolve + if: | + needs.resolve.outputs.has-targets == 'true' && + contains(needs.resolve.outputs.targets, '"stack":"container"') + strategy: + matrix: + target: ${{ fromJson(needs.resolve.outputs.targets) }} + fail-fast: false + concurrency: + group: image-build-${{ matrix.target.stack_id }}-${{ matrix.target.service }}-${{ github.sha }} + cancel-in-progress: false + uses: $/.github/workflows/reusable--container-builder.yaml + with: + image-name: ${{ matrix.target.stack == 'container' && matrix.target.service || '' }} + working-directory: ${{ matrix.target.working_directory }} + app-id: ${{ inputs.app-id }} + secrets: + private-key: ${{ secrets.private-key }} + + deploy-kustomize: + name: 'Deploy Kustomize (${{ matrix.target.service }}:${{ matrix.target.environment }})' + needs: resolve + if: | + github.event_name == 'pull_request' && + needs.resolve.outputs.has-targets == 'true' && + contains(needs.resolve.outputs.targets, '"stack":"kustomize"') + strategy: + matrix: + target: ${{ fromJson(needs.resolve.outputs.targets) }} + fail-fast: false + uses: $/.github/workflows/reusable--kubernetes-builder.yaml + with: + service-name: ${{ matrix.target.service }} + environment: ${{ matrix.target.environment }} + kustomization-directory: ${{ matrix.target.stack == 'kustomize' && matrix.target.working_directory || '' }} + app-id: ${{ inputs.app-id }} + secrets: + private-key: ${{ secrets.private-key }} + + helmfile-targets-group: + name: 'Group Helmfile Targets by Environment' + needs: resolve + if: | + needs.resolve.outputs.has-targets == 'true' && + contains(needs.resolve.outputs.targets, '"stack":"helmfile"') + runs-on: ubuntu-latest + outputs: + environments: ${{ steps.group.outputs.environments }} + steps: + - name: Group by environment + id: group + env: + TARGETS: ${{ needs.resolve.outputs.targets }} + run: | + set -euo pipefail + grouped=$(echo "$TARGETS" | jq -c ' + [.[] | select(.stack == "helmfile")] + | group_by(.environment) + | map({environment: .[0].environment, services: [.[].service]}) + ') + echo "environments=$grouped" >> "$GITHUB_OUTPUT" + + deploy-helmfile-hydrate: + name: 'Hydrate Helmfile (${{ matrix.env.environment }})' + needs: helmfile-targets-group + strategy: + matrix: + env: ${{ fromJson(needs.helmfile-targets-group.outputs.environments) }} + fail-fast: false + uses: $/.github/workflows/reusable--kubernetes-hydrator.yaml + with: + environment: ${{ matrix.env.environment }} + services: ${{ toJson(matrix.env.services) }} + app-id: ${{ inputs.app-id }} + secrets: + private-key: ${{ secrets.private-key }} + + deploy-helmfile: + name: 'Deploy Helmfile (${{ matrix.target.service }}:${{ matrix.target.environment }})' + needs: [resolve, deploy-helmfile-hydrate] + if: | + github.event_name == 'pull_request' && + needs.resolve.outputs.has-targets == 'true' && + contains(needs.resolve.outputs.targets, '"stack":"helmfile"') + strategy: + matrix: + target: ${{ fromJson(needs.resolve.outputs.targets) }} + fail-fast: false + uses: $/.github/workflows/reusable--kubernetes-builder.yaml + with: + service-name: ${{ matrix.target.service }} + environment: ${{ matrix.target.environment }} + kustomization-directory: ${{ matrix.target.stack == 'helmfile' && format('kubernetes/manifests/{0}/{1}', matrix.target.environment, matrix.target.service) || '' }} + # The hydrate job commits to the pull request branch, which the default merge ref does not contain. + ref: ${{ github.head_ref }} + app-id: ${{ inputs.app-id }} + secrets: + private-key: ${{ secrets.private-key }} + + deployment-summary: + name: 'Deployment Summary' + needs: [resolve, deploy-terragrunt, deploy-container, deploy-kustomize, deploy-helmfile-hydrate, deploy-helmfile] + if: always() && needs.resolve.outputs.has-targets == 'true' + runs-on: ubuntu-latest + steps: + - name: Summary + run: | + echo "๐Ÿ“Š Deployment Summary" + echo "Event: ${{ github.event_name }}" + echo "Branch: ${{ github.ref_name }}" + echo "Targets: ${{ needs.resolve.outputs.targets }}" + echo "Terragrunt Job Status: ${{ needs.deploy-terragrunt.result }}" + echo "Container Job Status: ${{ needs.deploy-container.result }}" + echo "Kustomize Job Status: ${{ needs.deploy-kustomize.result }}" + echo "Helmfile Hydrate Job Status: ${{ needs.deploy-helmfile-hydrate.result }}" + echo "Helmfile Job Status: ${{ needs.deploy-helmfile.result }}" diff --git a/.github/workflows/reusable--lint-actions.yaml b/.github/workflows/reusable--lint-actions.yaml new file mode 100644 index 0000000..87f0361 --- /dev/null +++ b/.github/workflows/reusable--lint-actions.yaml @@ -0,0 +1,13 @@ +# Checks that every action is pinned to a full commit SHA. +name: 'Reusable - Lint GitHub Actions' + +on: + workflow_call: + +jobs: + ensure-sha-pinned: + name: Ensure actions are pinned to SHAs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: zgosalvez/github-actions-ensure-sha-pinned-actions@c4e71056006d29f90204b2d063be337109507d62 # v5.0.10 diff --git a/.github/workflows/reusable--release--build.yaml b/.github/workflows/reusable--release--build.yaml new file mode 100644 index 0000000..ed05bff --- /dev/null +++ b/.github/workflows/reusable--release--build.yaml @@ -0,0 +1,70 @@ +# Builds the container image of a published service release or a dispatched tag and tags it with the release version. +name: 'Reusable - Release - Build' + +on: + workflow_call: + inputs: + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + detect-component: + runs-on: ubuntu-latest + outputs: + service: ${{ steps.parse.outputs.service }} + version: ${{ steps.parse.outputs.version }} + working-directory: ${{ steps.resolve-dir.outputs.working-directory }} + steps: + - name: Parse tag + id: parse + env: + # `inputs` in a reusable workflow holds only its own workflow_call inputs, so the caller's dispatch input `tag` is read from the event. + TAG: ${{ github.event.release.tag_name || github.event.inputs.tag }} + run: | + set -euo pipefail + if [[ ! "$TAG" =~ ^([a-z-]+)-v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "::error::Tag '$TAG' does not match expected pattern -v" + exit 1 + fi + SERVICE="${BASH_REMATCH[1]}" + VERSION="v${BASH_REMATCH[2]}" + echo "service=$SERVICE" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Resolve working directory + id: resolve-dir + env: + SERVICE: ${{ steps.parse.outputs.service }} + run: | + set -euo pipefail + # Use workflow-config.yaml as the source of truth for deployable stacks. + while IFS= read -r path_pattern; do + dir="${path_pattern//\{service\}/$SERVICE}" + if [ -d "$dir" ]; then + echo "working-directory=$dir" >> "$GITHUB_OUTPUT" + exit 0 + fi + done < <(yq -r '.stacks[] | select(.name == "container") | .paths[]' workflow-config.yaml) + echo "::error::No directory found for service '$SERVICE' under any container stack path in workflow-config.yaml" + exit 1 + + container-build: + needs: detect-component + concurrency: + group: image-build-${{ needs.detect-component.outputs.service }}-${{ github.sha }} + cancel-in-progress: false + uses: $/.github/workflows/reusable--container-builder.yaml + with: + image-name: ${{ needs.detect-component.outputs.service }} + working-directory: ${{ needs.detect-component.outputs.working-directory }} + app-id: ${{ inputs.app-id }} + semver-tag: ${{ needs.detect-component.outputs.version }} + secrets: + private-key: ${{ secrets.private-key }} diff --git a/.github/workflows/reusable--release--deploy.yaml b/.github/workflows/reusable--release--deploy.yaml new file mode 100644 index 0000000..e53e9c6 --- /dev/null +++ b/.github/workflows/reusable--release--deploy.yaml @@ -0,0 +1,74 @@ +# Applies a service's apply_trigger: release environments when its release is published or its tag is dispatched. +name: 'Reusable - Release - Deploy' + +on: + workflow_call: + inputs: + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + resolve: + name: 'Resolve Release Targets' + runs-on: ubuntu-latest + permissions: + contents: read + env: + # `inputs` in a reusable workflow holds only its own workflow_call inputs, so the caller's dispatch input `tag` is read from the event. + TAG: ${{ github.event.release.tag_name || github.event.inputs.tag }} + outputs: + targets: ${{ steps.resolve.outputs.targets }} + tag: ${{ steps.resolve.outputs.tag }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.TAG }} + + - name: Checkout shared workflows + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared-workflows + persist-credentials: false + + - name: Resolve release targets + id: resolve + run: | + set -euo pipefail + service=$(bash .shared-workflows/scripts/terragrunt-release/release-service.sh --tag "$TAG") + targets=$(bash .shared-workflows/scripts/terragrunt-release/release-targets.sh "$service") + echo "targets=$targets" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + apply: + name: 'Apply Release (${{ matrix.target.service }}:${{ matrix.target.environment }})' + needs: resolve + if: needs.resolve.outputs.targets != '[]' + concurrency: + group: terragrunt-apply-${{ matrix.target.working_directory }} + cancel-in-progress: false + strategy: + matrix: + target: ${{ fromJson(needs.resolve.outputs.targets) }} + fail-fast: false + uses: $/.github/workflows/reusable--terragrunt-runner.yaml + with: + service-name: ${{ matrix.target.service }} + environment: ${{ matrix.target.environment }} + action-type: apply + iam-role: ${{ matrix.target.iam_role_apply }} + aws-region: ${{ matrix.target.aws_region }} + working-directory: ${{ matrix.target.working_directory }} + app-id: ${{ inputs.app-id }} + ref: ${{ needs.resolve.outputs.tag }} + github-environment: ${{ matrix.target.environment }} + secrets: + private-key: ${{ secrets.private-key }} diff --git a/.github/workflows/reusable--release--plan.yaml b/.github/workflows/reusable--release--plan.yaml new file mode 100644 index 0000000..dc2cdf8 --- /dev/null +++ b/.github/workflows/reusable--release--plan.yaml @@ -0,0 +1,65 @@ +# Plans a service's apply_trigger: release environments on its release-please pull request. +name: 'Reusable - Release - Plan' + +on: + workflow_call: + inputs: + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + resolve: + name: 'Resolve Release Targets' + if: startsWith(github.head_ref, 'release-please--branches--main--components--') + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + targets: ${{ steps.resolve.outputs.targets }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Checkout shared workflows + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared-workflows + persist-credentials: false + + - name: Resolve release targets + id: resolve + env: + HEAD_REF: ${{ github.head_ref }} + run: | + set -euo pipefail + service=$(bash .shared-workflows/scripts/terragrunt-release/release-service.sh --branch "$HEAD_REF") + targets=$(bash .shared-workflows/scripts/terragrunt-release/release-targets.sh "$service") + echo "targets=$targets" >> "$GITHUB_OUTPUT" + + plan: + name: 'Plan Release (${{ matrix.target.service }}:${{ matrix.target.environment }})' + needs: resolve + if: needs.resolve.outputs.targets != '[]' + strategy: + matrix: + target: ${{ fromJson(needs.resolve.outputs.targets) }} + fail-fast: false + uses: $/.github/workflows/reusable--terragrunt-runner.yaml + with: + service-name: ${{ matrix.target.service }} + environment: ${{ matrix.target.environment }} + action-type: plan + iam-role: ${{ matrix.target.iam_role_plan }} + aws-region: ${{ matrix.target.aws_region }} + working-directory: ${{ matrix.target.working_directory }} + app-id: ${{ inputs.app-id }} + secrets: + private-key: ${{ secrets.private-key }} diff --git a/.github/workflows/reusable--release--please.yaml b/.github/workflows/reusable--release--please.yaml new file mode 100644 index 0000000..fd51f83 --- /dev/null +++ b/.github/workflows/reusable--release--please.yaml @@ -0,0 +1,60 @@ +# Runs release-please for each release-please-config.json to open release pull requests and publish releases. +name: 'Reusable - Release - Please' + +on: + workflow_call: + inputs: + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + discover: + runs-on: ubuntu-latest + outputs: + paths: ${{ steps.find.outputs.paths }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Checkout shared workflows + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared-workflows + persist-credentials: false + + - name: Find release-please configs + id: find + run: | + set -euo pipefail + paths=$(bash .shared-workflows/scripts/release-please/find-configs.sh) + echo "paths=$paths" >> "$GITHUB_OUTPUT" + + release-please: + needs: discover + if: needs.discover.outputs.paths != '[]' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + path: ${{ fromJson(needs.discover.outputs.paths) }} + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ secrets.private-key }} + + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5 + with: + token: ${{ steps.app-token.outputs.token }} + config-file: ${{ matrix.path == '.' && 'release-please-config.json' || format('{0}/release-please-config.json', matrix.path) }} + manifest-file: ${{ matrix.path == '.' && 'release-please-manifest.json' || format('{0}/release-please-manifest.json', matrix.path) }} diff --git a/.github/workflows/reusable--renovate-major-review.yaml b/.github/workflows/reusable--renovate-major-review.yaml new file mode 100644 index 0000000..a299a8f --- /dev/null +++ b/.github/workflows/reusable--renovate-major-review.yaml @@ -0,0 +1,122 @@ +# Reviews Renovate major-update pull requests with Claude on Bedrock and posts the review. +name: 'Reusable - Renovate Major Review' + +on: + workflow_call: + inputs: + prompt-path: + required: true + type: string + description: 'Path of the prompt file in the calling repository (e.g. .github/prompts/renovate-major-review.md)' + +jobs: + render: + name: 'Render' + if: github.event.label.name == '๐Ÿ“ˆ major-update' && github.event.pull_request.user.login == 'renovate[bot]' + runs-on: ubuntu-latest + timeout-minutes: 15 + # helmfile templates can exec commands, so PR content is rendered in a job without id-token or the review environment. + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Checkout shared workflows + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared-workflows + persist-credentials: false + + - name: Setup aqua + uses: aquaproj/aqua-installer@82d0e83878a8657c4a6f2ff1755d3bfbfc67f329 # v4.1.0 + with: + aqua_version: v2.64.0 + + - name: Render manifest diff + # HEAD is the PR merge commit, so its first parent is the base it merges into. + run: bash .shared-workflows/scripts/kubernetes-hydrate/render-diff.sh HEAD^1 "${RUNNER_TEMP}/render-diff" + + - name: Upload manifest diff + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: render-diff + path: ${{ runner.temp }}/render-diff + retention-days: 1 + + review: + name: 'Review' + needs: render + runs-on: ubuntu-latest + timeout-minutes: 30 + # Environment-scoped OIDC sub keeps a prompt-injected run from assuming the plan roles, which trust the pull_request sub. + environment: renovate-major-review + permissions: + contents: read + pull-requests: read + id-token: write + outputs: + body: ${{ steps.claude.outputs.structured_output && fromJSON(steps.claude.outputs.structured_output).body || '' }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Download manifest diff + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: render-diff + path: .render-diff + + - name: Setup aqua + uses: aquaproj/aqua-installer@82d0e83878a8657c4a6f2ff1755d3bfbfc67f329 # v4.1.0 + with: + aqua_version: v2.64.0 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 + with: + role-to-assume: arn:aws:iam::559744160976:role/github-oidc-auth-master-github-actions-bedrock-role + aws-region: us-east-1 + + - name: Review with Claude + id: claude + uses: anthropics/claude-code-action@16f9f71ab9c490faf367f94dd294897edc25be33 # v1.0.261 + env: + # The Bedrock role only allows Sonnet 4.6, so background tasks must not fall back to Haiku. + ANTHROPIC_DEFAULT_HAIKU_MODEL: global.anthropic.claude-sonnet-4-6 + with: + use_bedrock: 'true' + # Read-only token; without it the action exchanges OIDC for the Claude App's write token. + github_token: ${{ github.token }} + allowed_bots: renovate + prompt: | + Read `${{ inputs.prompt-path }}` and follow it. + Pull request number: ${{ github.event.pull_request.number }} + claude_args: | + --model global.anthropic.claude-sonnet-4-6 + --json-schema '{"type":"object","properties":{"body":{"type":"string","minLength":1,"maxLength":60000}},"required":["body"]}' + --allowedTools "Read,Glob,Grep,WebFetch,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh api:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(helm repo add:*),Bash(helm repo update:*),Bash(helm pull:*),Bash(helm show:*)" + + post: + name: 'Post Review' + needs: review + if: needs.review.result == 'success' && needs.review.outputs.body != '' + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Upsert review comment + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 + with: + message: ${{ needs.review.outputs.body }} + comment-tag: renovate-major-review + mode: upsert + pr-number: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/reusable--semantic-pull-request.yaml b/.github/workflows/reusable--semantic-pull-request.yaml new file mode 100644 index 0000000..a81b462 --- /dev/null +++ b/.github/workflows/reusable--semantic-pull-request.yaml @@ -0,0 +1,30 @@ +# Validates that the pull request title follows Conventional Commits. +name: 'Reusable - Semantic Pull Request' + +on: + workflow_call: + inputs: + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + validate: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ secrets.private-key }} + + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/reusable--terragrunt-runner.yaml b/.github/workflows/reusable--terragrunt-runner.yaml new file mode 100644 index 0000000..35342ff --- /dev/null +++ b/.github/workflows/reusable--terragrunt-runner.yaml @@ -0,0 +1,95 @@ +# Runs terragrunt plan or apply in one working directory and reports the result with tfcmt. +name: 'Reusable - Terragrunt Runner' + +on: + workflow_call: + inputs: + service-name: + required: true + type: string + description: 'Service name for terragrunt execution' + environment: + required: true + type: string + description: 'Environment name (develop, staging, production, etc.)' + action-type: + required: true + type: string + description: 'Action type: plan or apply' + iam-role: + required: true + type: string + description: 'IAM role for plan/apply operations' + aws-region: + required: true + type: string + description: 'AWS region' + working-directory: + required: true + type: string + description: 'Working directory for terragrunt execution' + app-id: + required: true + type: string + description: 'GitHub App ID for authentication' + ref: + required: false + type: string + default: '' + description: 'Git ref to check out (defaults to the triggering ref)' + github-environment: + required: false + type: string + default: '' + description: 'GitHub environment the job runs in (none when empty)' + secrets: + private-key: + required: true + description: 'GitHub App private key for authentication' + +jobs: + run-terragrunt: + name: 'Run Terragrunt' + if: inputs.action-type != '' + runs-on: ubuntu-latest + environment: ${{ inputs.github-environment }} + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ secrets.private-key }} + owner: ${{ github.repository_owner }} + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.ref }} + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 + with: + role-to-assume: ${{ inputs.iam-role }} + aws-region: ${{ inputs.aws-region }} + role-session-name: GitHubActions-Terragrunt-${{ inputs.action-type }}-${{ inputs.environment }} + audience: sts.amazonaws.com + + - name: Setup aqua + uses: aquaproj/aqua-installer@82d0e83878a8657c4a6f2ff1755d3bfbfc67f329 # v4.1.0 + with: + aqua_version: v2.64.0 + + - name: Run Terragrunt + working-directory: ${{ inputs.working-directory }} + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + TF_INPUT: 'false' + # tfcmt cannot parse the output while terragrunt prefixes each line with its log metadata. + TG_LOG_DISABLE: 'true' + TFCMT_PLAN_PATCH: 'true' + TFCMT_DISABLE_LABEL: 'true' + SERVICE_NAME: ${{ inputs.service-name }} + ENVIRONMENT: ${{ inputs.environment }} + ACTION_TYPE: ${{ inputs.action-type }} + run: tfcmt -var "target:$SERVICE_NAME/$ENVIRONMENT" "$ACTION_TYPE" -- terragrunt "$ACTION_TYPE" -no-color ${{ inputs.action-type == 'apply' && '-auto-approve' || '' }} diff --git a/.github/workflows/reusable--test-terragrunt-release.yaml b/.github/workflows/reusable--test-terragrunt-release.yaml new file mode 100644 index 0000000..b7c14ba --- /dev/null +++ b/.github/workflows/reusable--test-terragrunt-release.yaml @@ -0,0 +1,29 @@ +# Validates the caller's terragrunt apply_trigger values and the release-please configs of its release environments. +name: 'Reusable - Test Terragrunt Release' + +on: + workflow_call: + +jobs: + validate: + name: 'Validate terragrunt release config' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Checkout shared workflows + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared-workflows + persist-credentials: false + + - name: Validate apply_trigger + run: bash .shared-workflows/scripts/terragrunt-release/validate-apply-trigger.sh + + - name: Check release-please configs + run: bash .shared-workflows/scripts/terragrunt-release/check-release-configs.sh diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml new file mode 100644 index 0000000..8d1ba09 --- /dev/null +++ b/.github/workflows/semantic-pull-request.yml @@ -0,0 +1,16 @@ +# Validates that the pull request title follows Conventional Commits. +name: Semantic Pull Request + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + +jobs: + semantic: + permissions: + contents: read + uses: ./.github/workflows/reusable--semantic-pull-request.yaml + with: + app-id: ${{ vars.APP_ID }} + secrets: + private-key: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.github/workflows/test-scripts.yaml b/.github/workflows/test-scripts.yaml new file mode 100644 index 0000000..2145863 --- /dev/null +++ b/.github/workflows/test-scripts.yaml @@ -0,0 +1,33 @@ +name: Test Scripts + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Test scripts + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup aqua + uses: aquaproj/aqua-installer@82d0e83878a8657c4a6f2ff1755d3bfbfc67f329 # v4.1.0 + with: + aqua_version: v2.64.0 + + - name: Run script tests + run: | + status=0 + for test_script in scripts/*/test.sh scripts/*/*.test.sh scripts/kubernetes-hydrate/tests/*.test.sh; do + echo "::group::${test_script}" + bash "${test_script}" || status=1 + echo "::endgroup::" + done + exit "${status}" diff --git a/.github/workflows/test-shared-workflows--checkout.yaml b/.github/workflows/test-shared-workflows--checkout.yaml new file mode 100644 index 0000000..55aa3f6 --- /dev/null +++ b/.github/workflows/test-shared-workflows--checkout.yaml @@ -0,0 +1,39 @@ +# Checks that the self checkout resolves to the running workflow commit and that its scripts run. +name: 'Test Shared Workflows - Checkout' + +on: + workflow_call: + +jobs: + self-checkout: + name: Self checkout + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Checkout shared workflows + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .shared-workflows + persist-credentials: false + + - name: Verify the self checkout is the running workflow commit + env: + WORKFLOW_SHA: ${{ job.workflow_sha }} + run: | + actual="$(git -C .shared-workflows rev-parse HEAD)" + if [ "${actual}" != "${WORKFLOW_SHA}" ]; then + echo "expected ${WORKFLOW_SHA}, got ${actual}" >&2 + exit 1 + fi + + - name: Run a script from the self checkout + run: | + actual="$(bash .shared-workflows/scripts/release-please/find-configs.sh)" + if [ "${actual}" != '["."]' ]; then + echo "expected [\".\"], got ${actual}" >&2 + exit 1 + fi diff --git a/.github/workflows/test-shared-workflows--nested.yaml b/.github/workflows/test-shared-workflows--nested.yaml new file mode 100644 index 0000000..711d47d --- /dev/null +++ b/.github/workflows/test-shared-workflows--nested.yaml @@ -0,0 +1,9 @@ +# Calls the self checkout test through `$/` the way the shared workflows call each other. +name: 'Test Shared Workflows - Nested' + +on: + workflow_call: + +jobs: + checkout: + uses: $/.github/workflows/test-shared-workflows--checkout.yaml diff --git a/.github/workflows/test-shared-workflows.yaml b/.github/workflows/test-shared-workflows.yaml new file mode 100644 index 0000000..f346dd9 --- /dev/null +++ b/.github/workflows/test-shared-workflows.yaml @@ -0,0 +1,13 @@ +# Exercises the nested reusable workflow call and the self checkout that the shared workflows rely on. +name: Test Shared Workflows + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + permissions: + contents: read + uses: ./.github/workflows/test-shared-workflows--nested.yaml diff --git a/README.md b/README.md index 3519f07..faab4f3 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,41 @@ panicboat ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฎใƒชใƒใ‚ธใƒˆใƒชใงๅ…ฑๆœ‰ใ™ใ‚‹ GitHub ใฎ่จญๅฎšใ‚’ ใ“ใ“ใซใ‚ใ‚‹ issue ใƒ†ใƒณใƒ—ใƒฌใƒผใƒˆใจ PR ใƒ†ใƒณใƒ—ใƒฌใƒผใƒˆใฏใ€่‡ชๅ‰ใฎใƒ†ใƒณใƒ—ใƒฌใƒผใƒˆใ‚’ๆŒใŸใชใ„ใƒชใƒใ‚ธใƒˆใƒชใงๆ—ขๅฎšใจใ—ใฆไฝฟใ‚ใ‚Œใ‚‹ใ€‚ ใƒชใƒใ‚ธใƒˆใƒชใŒ่‡ชๅ‰ใฎ `.github/ISSUE_TEMPLATE/` ใ‚’ๆŒใคใจใ€ใ“ใ“ใฎ issue ใƒ†ใƒณใƒ—ใƒฌใƒผใƒˆใฏใƒ•ใ‚ฉใƒซใƒ€ใ”ใจไฝฟใ‚ใ‚Œใชใ„ใ€‚ + +## Shared Workflows + +`.github/workflows/reusable--*.yaml` ใฏใ€panicboat ใฎไป–ใฎใƒชใƒใ‚ธใƒˆใƒชใ‹ใ‚‰ๅ‘ผใณๅ‡บใ™ๅ…ฑ้€šใฎ workflowใ€‚ +ๅ‘ผใณๅ‡บใ—ๅดใฏ `uses: panicboat/.github/.github/workflows/@ # vX.Y.Z` ใจๆ›ธใ„ใฆใ€release ใฎ commit SHA ใซๅ›บๅฎšใ™ใ‚‹ใ€‚ +ๅ›บๅฎšใ—ใŸ SHA ใฏ Renovate ใŒๆ›ดๆ–ฐใ™ใ‚‹ใ€‚ + +```yaml +jobs: + lint: + permissions: + contents: read + uses: panicboat/.github/.github/workflows/reusable--lint-actions.yaml@ # vX.Y.Z +``` + +- ๅ‘ผใณๅ‡บใ™ job ใฏใ€ๅ‘ผใฐใ‚Œใ‚‹ workflow ใŒๅฟ…่ฆใจใ™ใ‚‹ๆจฉ้™ใ‚’ `permissions` ใงไป˜ใ‘ใ‚‹ใ€‚ + - ่ค‡ๆ•ฐใฎ stack ใ‚’ใพใจใ‚ใ‚‹ workflow ใฏ job ใฎ `permissions` ใ‚’ๅฎฃ่จ€ใ—ใชใ„ใ€‚ + - ๅ‘ผใณๅ‡บใ—ๅดใŒใ€ใใฎใƒชใƒใ‚ธใƒˆใƒชใงไฝฟใ† stack ใฎๅˆ†ใ ใ‘ใ‚’ไป˜ใ‘ใ‚‹ใ€‚ +- ๅ…ฑ้€šใฎ workflow ใ‹ใ‚‰ๅˆฅใฎๅ…ฑ้€šใฎ workflow ใ‚’ๅ‘ผใถใจใใฏใ€`uses: $/.github/workflows/` ใจๆ›ธใใ€‚ + - `$/` ใฏๅ‘ผใณๅ‡บใ—ๅ…ƒใฎ workflow ใจๅŒใ˜ commit ใซ่งฃๆฑบใ•ใ‚Œใ‚‹ใ€‚ + - ๅ…ฅใ‚Œๅญใฎ workflow ใŒใ€ๅ‘ผใณๅ‡บใ—ๅดใฎๅ›บๅฎšใจใšใ‚Œใชใ„ใ€‚ +- `scripts/` ใฎ script ใฏใ€`job.workflow_repository` ใ‚’ `job.workflow_sha` ใง `.shared-workflows/` ใซ checkout ใ—ใฆๅฎŸ่กŒใ™ใ‚‹ใ€‚ + - ๅ‘ผใณๅ‡บใ—ๅดใŒๅ›บๅฎšใ—ใŸ workflow ใจๅŒใ˜ commit ใฎ script ใ‚’ไฝฟใ†ใŸใ‚ใ€‚ +- ใ“ใฎใƒชใƒใ‚ธใƒˆใƒชใฏ public ใฎใพใพใซใ™ใ‚‹ใ€‚ + - ๅ‘ผใณๅ‡บใ—ๅดใŒใ€่‡ชๅˆ†ใฎ `GITHUB_TOKEN` ใงใ“ใฎใƒชใƒใ‚ธใƒˆใƒชใ‚’ checkout ใ™ใ‚‹ใŸใ‚ใ€‚ + +## Renovate Preset + +`default.json` ใฏใ€panicboat ใฎใƒชใƒใ‚ธใƒˆใƒชใงๅ…ฑ้€šใฎ Renovate ่จญๅฎšใ‚’ๆŒใคใ€‚ +ๅ„ใƒชใƒใ‚ธใƒˆใƒชใฏ `extends` ใซใ“ใฎ preset ใ‚’ๆ›ธใใ€ใใฎใƒชใƒใ‚ธใƒˆใƒชๅ›บๆœ‰ใฎ rule ใ ใ‘ใ‚’่‡ชๅˆ†ใฎ `renovate.json` ใซๆฎ‹ใ™ใ€‚ + +- ไป–ใฎใƒชใƒใ‚ธใƒˆใƒชใฏ `"extends": ["github>panicboat/.github"]` ใจๆ›ธใใ€‚ + - Renovate ใฎ docs ใŒใ€ๅˆฅใฎใƒชใƒใ‚ธใƒˆใƒชใซ็ฝฎใ„ใŸ preset ใฎๅ‚็…งใจใ—ใฆ็คบใ™ๆ›ธๅผใ€‚ +- ใ“ใฎใƒชใƒใ‚ธใƒˆใƒชใฏ `"extends": ["local>panicboat/.github"]` ใจๆ›ธใใ€‚ + - `local>` ใฏใ€ๅฏพ่ฑกใฎใƒชใƒใ‚ธใƒˆใƒชใจๅŒใ˜ platform ใซใ‚ใ‚‹ preset ใ‚’ๅ‚็…งใ™ใ‚‹ๆ›ธๅผใ€‚ + - github.com ใงใฏ `github>` ใจๅŒใ˜ใซ่งฃๆฑบใ•ใ‚Œใ‚‹ใ€‚ +- `panicboat/.github` ใธใฎๅ‚็…งใฏใ€`minimumReleaseAge` ใ‚’ๅพ…ใŸใšใซๆ›ดๆ–ฐใ™ใ‚‹ใ€‚ + - ๅ…ฑ้€šใฎ workflow ใฏ panicboat ่‡ช่บซใŒ release ใ™ใ‚‹ใฎใงใ€ๅ…ฌ้–‹ใ‹ใ‚‰ๆ—ฅใ‚’็ฝฎใ„ใฆๆง˜ๅญใ‚’่ฆ‹ใ‚‹็†็”ฑใŒใชใ„ใ€‚ diff --git a/aqua.yaml b/aqua.yaml new file mode 100644 index 0000000..b3f08e0 --- /dev/null +++ b/aqua.yaml @@ -0,0 +1,7 @@ +registries: + - type: standard + ref: v4.311.0 # renovate: github_release aquaproj/aqua-registry + +packages: + - name: kubernetes-sigs/kustomize@kustomize/v5.6.0 + - name: mikefarah/yq@v4.54.1 diff --git a/default.json b/default.json new file mode 100644 index 0000000..cfd5f2e --- /dev/null +++ b/default.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":semanticCommits", + ":separateMajorReleases", + "group:monorepos", + "group:recommended", + "helpers:pinGitHubActionDigests" + ], + "semanticCommitScope": "{{packageFileDir}}", + "timezone": "Asia/Tokyo", + "labels": [ + "๐Ÿค– renovate" + ], + "assigneesFromCodeOwners": false, + "reviewersFromCodeOwners": true, + "automergeType": "pr", + "automergeStrategy": "squash", + "platformAutomerge": true, + "prConcurrentLimit": 15, + "prHourlyLimit": 5, + "branchPrefix": "renovate/", + "rangeStrategy": "bump", + "schedule": [ + "before 4am every weekday" + ], + "minimumReleaseAge": "1 day", + "separateMinorPatch": false, + "packageRules": [ + { + "description": "Default: separate PRs per package", + "groupName": "{{packageFileDir}}/{{depName}}", + "matchPackageNames": [ + "*" + ] + }, + { + "description": "๐Ÿšซ Production environments - disable automerge", + "matchFileNames": [ + "**/production/**" + ], + "automerge": false, + "prPriority": -1, + "addLabels": [ + "โš ๏ธ production" + ] + }, + { + "description": "๐Ÿšซ Major updates - disable automerge", + "matchUpdateTypes": [ + "major" + ], + "automerge": false, + "addLabels": [ + "๐Ÿ“ˆ major-update" + ] + }, + { + "description": "โœ… Minor/Patch updates - enable automerge", + "matchUpdateTypes": [ + "minor", + "patch", + "pin", + "digest" + ], + "automerge": true + }, + { + "description": "Shared workflows from panicboat/.github apply without waiting for a release age", + "matchPackageNames": [ + "panicboat/.github" + ], + "minimumReleaseAge": null + } + ] +} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..091330f --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "separate-pull-requests": true, + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance Improvements" }, + { "type": "revert", "section": "Reverts" }, + { "type": "refactor", "section": "Code Refactoring" }, + { "type": "chore", "section": "Miscellaneous Chores" }, + { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "style", "section": "Styles", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "build", "section": "Build System", "hidden": true }, + { "type": "ci", "section": "Continuous Integration", "hidden": true } + ], + "packages": { + ".": { + "release-type": "simple", + "include-component-in-tag": false, + "release-as": "1.0.0" + } + } +} diff --git a/release-please-manifest.json b/release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/scripts/docs-sync-check/decide.sh b/scripts/docs-sync-check/decide.sh new file mode 100755 index 0000000..14245b0 --- /dev/null +++ b/scripts/docs-sync-check/decide.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Read stdin before any early exit so the upstream command under pipefail does not die of SIGPIPE. +changed_files="$(cat)" +scope_pattern="${SCOPE_PATTERN:?SCOPE_PATTERN is required}" +is_approved="$APPROVED" +remove_label=false + +emit() { + echo "mode=$1" + echo "remove_label=${remove_label}" +} + +case "$EVENT_ACTION" in + labeled | unlabeled) + if [ "$EVENT_LABEL" != "docs-check-approved" ]; then + emit ignore + exit 0 + fi + ;; +esac + +if [ "$AUTHOR_TYPE" = "Bot" ]; then + emit skip-bot + exit 0 +fi + +if [ "$EVENT_ACTION" = "edited" ] && [ "$BODY_OR_BASE_CHANGED" != "true" ]; then + emit ignore + exit 0 +fi + +case "$EVENT_ACTION" in + labeled) + # Approval needs an existing verdict on the commit, since a label set at PR creation can cancel the opened run. + if [ "$is_approved" = "true" ] && [ "$PRIOR_STATE" != "" ] && [ "$PRIOR_STATE" != "pending" ]; then + emit approved + exit 0 + fi + remove_label="$is_approved" + is_approved=false + ;; + unlabeled) + is_approved=false + ;; + synchronize | opened) + if [ "$is_approved" = "true" ]; then + is_approved=false + remove_label=true + fi + ;; +esac + +if [ "$is_approved" = "true" ]; then + emit ignore + exit 0 +fi + +scope_status=0 +grep -qE -e "$scope_pattern" <<<"$changed_files" || scope_status=$? +if [ "$scope_status" -gt 1 ]; then + echo "SCOPE_PATTERN is not a valid extended regular expression: ${scope_pattern}" >&2 + exit 1 +fi + +if [ "$scope_status" -eq 1 ]; then + emit skip-unchanged + exit 0 +fi + +emit judge diff --git a/scripts/docs-sync-check/decide.test.sh b/scripts/docs-sync-check/decide.test.sh new file mode 100755 index 0000000..26358c6 --- /dev/null +++ b/scripts/docs-sync-check/decide.test.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +decide="${script_dir}/decide.sh" +failed=0 + +check() { + local name="$1" action="$2" label="$3" approved="$4" author="$5" changed="$6" prior="$7" files="$8" expected_mode="$9" expected_remove="${10}" scope="${11:-^dystopia/}" + local expected actual + expected="$(printf 'mode=%s\nremove_label=%s' "$expected_mode" "$expected_remove")" + actual="$(printf '%s' "$files" | SCOPE_PATTERN="$scope" EVENT_ACTION="$action" EVENT_LABEL="$label" APPROVED="$approved" AUTHOR_TYPE="$author" BODY_OR_BASE_CHANGED="$changed" PRIOR_STATE="$prior" bash "$decide" 2>&1)" + if [ "$actual" = "$expected" ]; then + echo "PASS ${name}" + else + echo "FAIL ${name}" + echo " expected: ${expected//$'\n'/ | }" + echo " actual: ${actual//$'\n'/ | }" + failed=1 + fi +} + +check ignores_other_labels labeled deploy:frontend false User false '' $'dystopia/a.rb' ignore false +check ignores_other_labels_even_when_approved labeled deploy:frontend true User false '' $'dystopia/a.rb' ignore false +check ignores_other_label_removed unlabeled deploy:frontend false User false '' $'dystopia/a.rb' ignore false +check skips_bots opened '' false Bot false '' $'dystopia/a.rb' skip-bot false +check skips_without_dystopia_changes opened '' false User false '' $'platform/a.tf' skip-unchanged false +check skips_empty_diff opened '' false User false '' '' skip-unchanged false +check judges_dystopia_changes opened '' false User false '' $'README.md\ndystopia/docs/x.md' judge false +check does_not_match_prefix_only opened '' false User false '' $'dystopia-old/a.rb' skip-unchanged false +check passes_on_approval_label_after_failure labeled docs-check-approved true User false failure $'dystopia/a.rb' approved false +check passes_on_approval_label_after_success labeled docs-check-approved true User false success $'dystopia/a.rb' approved false +check approval_label_before_any_verdict_rejudges labeled docs-check-approved true User false '' $'dystopia/a.rb' judge true +check approval_label_while_judging_rejudges labeled docs-check-approved true User false pending $'dystopia/a.rb' judge true +check approval_label_already_removed_rejudges labeled docs-check-approved false User false failure $'dystopia/a.rb' judge false +check approval_label_before_any_verdict_without_dystopia labeled docs-check-approved true User false '' $'platform/a.tf' skip-unchanged true +check ignores_edit_while_approved edited '' true User true '' $'dystopia/a.rb' ignore false +check ignores_reopen_while_approved reopened '' true User false '' $'dystopia/a.rb' ignore false +check ignores_title_only_edit edited '' false User false '' $'dystopia/a.rb' ignore false +check judges_body_edit edited '' false User true '' $'dystopia/a.rb' judge false +check rejudges_when_approval_removed unlabeled docs-check-approved false User false '' $'dystopia/a.rb' judge false +check opened_with_approval_rejudges opened '' true User false '' $'dystopia/a.rb' judge true +check push_after_approval_rejudges synchronize '' true User false '' $'dystopia/a.rb' judge true +check push_after_approval_without_dystopia synchronize '' true User false '' $'platform/a.tf' skip-unchanged true +check skips_changes_outside_the_scope_pattern opened '' false User false '' $'dystopia/a.rb' skip-unchanged false '^platform/' +check judges_changes_inside_the_scope_pattern opened '' false User false '' $'platform/a.tf' judge false '^platform/' + +check_rejects_scope_pattern() { + local name="$1" author="$2" actual + shift 2 + if actual="$(printf '%s' $'dystopia/a.rb' | env "$@" EVENT_ACTION=opened EVENT_LABEL='' APPROVED=false AUTHOR_TYPE="$author" BODY_OR_BASE_CHANGED=false PRIOR_STATE='' bash "$decide" 2>&1)"; then + echo "FAIL ${name}" + echo " expected a non-zero exit, actual output: ${actual//$'\n'/ | }" + failed=1 + elif [[ "$actual" == *SCOPE_PATTERN* ]]; then + echo "PASS ${name}" + else + echo "FAIL ${name}" + echo " expected stderr to name SCOPE_PATTERN, actual: ${actual//$'\n'/ | }" + failed=1 + fi +} + +check_rejects_scope_pattern rejects_missing_scope_pattern_even_when_the_event_exits_early Bot -u SCOPE_PATTERN +check_rejects_scope_pattern rejects_invalid_scope_pattern User SCOPE_PATTERN='(' + +exit "$failed" diff --git a/scripts/kubernetes-hydrate/hydrate-component.sh b/scripts/kubernetes-hydrate/hydrate-component.sh new file mode 100755 index 0000000..1303421 --- /dev/null +++ b/scripts/kubernetes-hydrate/hydrate-component.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +component="${1:?component name required}" +env="${2:?environment name required}" + +cd "$(git rev-parse --show-toplevel)" + +component_dir="kubernetes/components/${component}/${env}" +out_dir="kubernetes/manifests/${env}/${component}" + +# Explicit --kube-version prevents template divergence caused by varying default helm binary versions. +env_hcl="aws/eks/${env}/env.hcl" +if [ ! -f "${env_hcl}" ]; then + echo "hydrate-component.sh: ${env_hcl} not found; cannot determine cluster version" >&2 + exit 1 +fi +kube_version=$(sed -n 's/^[[:space:]]*cluster_version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "${env_hcl}") +if [ -z "${kube_version}" ]; then + echo "hydrate-component.sh: cluster_version not set in ${env_hcl}" >&2 + exit 1 +fi + +mkdir -p "${out_dir}" +: > "${out_dir}/manifest.yaml" + +if [ -f "${component_dir}/helmfile.yaml" ]; then + helmfile -f "${component_dir}/helmfile.yaml" -e "${env}" template \ + --include-crds --skip-tests --kube-version "${kube_version}" >> "${out_dir}/manifest.yaml" +fi + +if [ -d "${component_dir}/kustomization" ]; then + echo "---" >> "${out_dir}/manifest.yaml" + kustomize build "${component_dir}/kustomization" >> "${out_dir}/manifest.yaml" +fi + +printf "resources:\n - manifest.yaml\n" > "${out_dir}/kustomization.yaml" + +if git ls-files --error-unmatch "${out_dir}/manifest.yaml" >/dev/null 2>&1; then + if git diff --quiet -I '^[[:space:]]*(ca\.crt|ca\.key|tls\.crt|tls\.key|caBundle):' -- "${out_dir}/manifest.yaml"; then + git checkout -- "${out_dir}/manifest.yaml" + fi +fi diff --git a/scripts/kubernetes-hydrate/hydrate-index.sh b/scripts/kubernetes-hydrate/hydrate-index.sh new file mode 100755 index 0000000..291dbd4 --- /dev/null +++ b/scripts/kubernetes-hydrate/hydrate-index.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +env="${1:?environment name required}" + +cd "$(git rev-parse --show-toplevel)" + +env_dir="kubernetes/manifests/${env}" + +mkdir -p "${env_dir}/00-namespaces" +: > "${env_dir}/00-namespaces/namespaces.yaml" + +for comp_dir in kubernetes/components/*/"${env}"/; do + [ -d "${comp_dir}" ] || continue + comp_name=$(basename "$(dirname "${comp_dir}")") + if [ -f "kubernetes/components/${comp_name}/${env}/namespace.yaml" ]; then + echo "---" >> "${env_dir}/00-namespaces/namespaces.yaml" + cat "kubernetes/components/${comp_name}/${env}/namespace.yaml" >> "${env_dir}/00-namespaces/namespaces.yaml" + elif [ -f "kubernetes/components/${comp_name}/namespace.yaml" ]; then + echo "---" >> "${env_dir}/00-namespaces/namespaces.yaml" + cat "kubernetes/components/${comp_name}/namespace.yaml" >> "${env_dir}/00-namespaces/namespaces.yaml" + fi +done + +printf "resources:\n - namespaces.yaml\n" > "${env_dir}/00-namespaces/kustomization.yaml" + +for dir in "${env_dir}"/*/; do + [ -d "${dir}" ] || continue + name=$(basename "${dir}") + if [ "${name}" = "00-namespaces" ]; then + continue + fi + if [ ! -d "kubernetes/components/${name}/${env}" ]; then + rm -rf "${dir}" + fi +done + +{ + echo "resources:" + echo " - ./00-namespaces" + # Sort by full path in C locale so prefix pairs like opentelemetry-collector precede opentelemetry. + for dir in "${env_dir}"/*/; do + [ -d "${dir}" ] || continue + name=$(basename "${dir}") + [ "${name}" = "00-namespaces" ] && continue + printf '%s\n' "${dir}" + done | LC_ALL=C sort | while IFS= read -r dir; do + echo " - ./$(basename "${dir}")" + done +} > "${env_dir}/kustomization.yaml" diff --git a/scripts/kubernetes-hydrate/render-diff.sh b/scripts/kubernetes-hydrate/render-diff.sh new file mode 100755 index 0000000..9eec558 --- /dev/null +++ b/scripts/kubernetes-hydrate/render-diff.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +base_ref="${1:?base revision required}" +out_dir="${2:?output directory required}" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$(git rev-parse --show-toplevel)" +mkdir -p "${out_dir}" +out_dir="$(cd "${out_dir}" && pwd)" +summary="${out_dir}/summary.md" + +components="$(git diff --name-only --no-renames "${base_ref}" HEAD -- kubernetes/components/ | awk -F/ 'NF >= 5 { print $3 "/" $4 }' | sort -u)" +if [ -z "${components}" ]; then + echo "No Kubernetes components changed." > "${summary}" + exit 0 +fi + +work="$(mktemp -d)" +cleanup() { + git worktree remove --force "${work}/base" 2>/dev/null || true # SILENT: the worktree may not exist when setup failed + git worktree remove --force "${work}/head" 2>/dev/null || true # SILENT: the worktree may not exist when setup failed + rm -rf "${work}" +} +trap cleanup EXIT +# Separate worktrees keep hydrate-component.sh from rewriting kubernetes/manifests in this checkout. +git worktree add -q --detach "${work}/base" "${base_ref}" +git worktree add -q --detach "${work}/head" HEAD + +render() { + local side="$1" svc="$2" env="$3" dir="$4" + (cd "${work}/${side}" && bash "${script_dir}/hydrate-component.sh" "${svc}" "${env}") > "${dir}/${side}.log" 2>&1 && + cp "${work}/${side}/kubernetes/manifests/${env}/${svc}/manifest.yaml" "${dir}/${side}.yaml" +} + +: > "${summary}" +for component in ${components}; do + svc="${component%/*}" + env="${component#*/}" + dir="${out_dir}/${env}/${svc}" + { + echo "## ${svc} (${env})" + echo + if [ ! -f "aws/eks/${env}/env.hcl" ]; then + echo "Not rendered: \`${env}\` has no \`aws/eks/${env}/env.hcl\`, so the hydrator never deploys it." + echo + continue + fi + mkdir -p "${dir}" + failed="" + render base "${svc}" "${env}" "${dir}" || failed="base" + render head "${svc}" "${env}" "${dir}" || failed="${failed:+${failed} }head" + if [ -n "${failed}" ]; then + echo "Rendering failed (${failed}):" + echo + echo '```' + for side in ${failed}; do + tail -n 20 "${dir}/${side}.log" + done + echo '```' + else + diff -u "${dir}/base.yaml" "${dir}/head.yaml" > "${dir}/manifest.diff" || true # SILENT: diff exits 1 when the manifests differ + echo "Full diff: \`${env}/${svc}/manifest.diff\`" + echo + if bash "${script_dir}/summarize-manifest-diff.sh" "${dir}/base.yaml" "${dir}/head.yaml" > "${dir}/summary.part" 2> "${dir}/summarize.log"; then + cat "${dir}/summary.part" + else + echo "Summarizing failed; use the full diff:" + echo + echo '```' + tail -n 20 "${dir}/summarize.log" + echo '```' + fi + fi + echo + } >> "${summary}" +done diff --git a/scripts/kubernetes-hydrate/summarize-manifest-diff.sh b/scripts/kubernetes-hydrate/summarize-manifest-diff.sh new file mode 100755 index 0000000..c3f23fa --- /dev/null +++ b/scripts/kubernetes-hydrate/summarize-manifest-diff.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +base="${1:?base manifest required}" +head="${2:?head manifest required}" + +export LC_ALL=C + +image_refs() { + { + yq -N '.. | select(tag == "!!map" and has("image")) | .image | select(tag == "!!str")' "$1" + yq -N '.. | select(tag == "!!str") | select(test("^(--[A-Za-z0-9-]+=)?[a-z0-9.-]+(:[0-9]+)?/[a-z0-9./_-]+:[A-Za-z0-9._-]+(@sha256:[a-f0-9]+)?$")) | sub("^--[A-Za-z0-9-]+=", "")' "$1" + } | sort -u +} + +split_image_refs() { + awk -v side="$1" '{ + ref = $0; digest = ""; tag = ""; repo = ref + at = index(ref, "@") + if (at > 0) { digest = substr(ref, at); ref = substr(ref, 1, at - 1); repo = ref } + n = split(ref, parts, "/"); last = parts[n] + colon = index(last, ":") + if (colon > 0) { tag = substr(last, colon + 1); repo = substr(ref, 1, length(ref) - length(last) + colon - 1) } + tag = tag digest + if (tag == "") tag = "(untagged)" + print repo "\t" side "\t" tag + }' +} + +crd_versions() { + yq -N 'select(.kind == "CustomResourceDefinition") | .metadata.name + "\t" + ([.spec.versions[] | .name + (if .served == false then " (not served)" else "" end) + (if .storage then "*" else "" end)] | join(", "))' "$1" | sort -u +} + +resources() { + yq -N 'select(.kind != null) | .kind + " " + (if .metadata.namespace then .metadata.namespace + "/" else "" end) + .metadata.name' "$1" | sort -u +} + +pair_rows() { + awk -F '\t' '{ + key[$1] = 1 + if ($2 == "base") base[$1] = (base[$1] == "" ? $3 : base[$1] ", " $3) + else head[$1] = (head[$1] == "" ? $3 : head[$1] ", " $3) + } + END { + for (k in key) if (base[k] != head[k]) print k "\t" (base[k] == "" ? "-" : base[k]) "\t" (head[k] == "" ? "-" : head[k]) + }' | sort +} + +print_table() { + local header="$1" rows="$2" + if [ -z "${rows}" ]; then + echo "No changes." + return + fi + echo "| ${header} | Base | Head |" + echo "|---|---|---|" + printf '%s\n' "${rows}" | awk -F '\t' '{ print "| " $1 " | " $2 " | " $3 " |" }' +} + +tmp="$(mktemp -d)" +trap 'rm -rf "${tmp}"' EXIT +# Extracting into files first lets set -e catch yq failures that command substitutions and process substitutions would hide. +for side in base head; do + if [ "${side}" = base ]; then manifest="${base}"; else manifest="${head}"; fi + image_refs "${manifest}" | split_image_refs "${side}" > "${tmp}/${side}.images" + crd_versions "${manifest}" | awk -F '\t' -v side="${side}" '{ print $1 "\t" side "\t" $2 }' > "${tmp}/${side}.crds" + resources "${manifest}" > "${tmp}/${side}.resources" +done + +image_rows="$(cat "${tmp}/base.images" "${tmp}/head.images" | pair_rows)" +crd_rows="$(cat "${tmp}/base.crds" "${tmp}/head.crds" | pair_rows)" +added="$(comm -13 "${tmp}/base.resources" "${tmp}/head.resources")" +removed="$(comm -23 "${tmp}/base.resources" "${tmp}/head.resources")" + +echo "### Images" +echo +if [ -n "${image_rows}" ]; then + echo 'Collected from `image` fields and from other string values shaped like `registry/repository:tag` (args, env).' + echo +fi +print_table "Repository" "${image_rows}" +echo +echo "### CustomResourceDefinitions" +echo +if [ -n "${crd_rows}" ]; then + echo '`*` marks the storage version.' + echo +fi +print_table "Name" "${crd_rows}" +echo +echo "### Resources" +echo +if [ -z "${added}" ] && [ -z "${removed}" ]; then + echo "No changes." +fi +if [ -n "${added}" ]; then + echo "Added:" + printf '%s\n' "${added}" | sed 's/^/- /' +fi +if [ -n "${added}" ] && [ -n "${removed}" ]; then + echo +fi +if [ -n "${removed}" ]; then + echo "Removed:" + printf '%s\n' "${removed}" | sed 's/^/- /' +fi diff --git a/scripts/kubernetes-hydrate/tests/fixtures/identical/expected.md b/scripts/kubernetes-hydrate/tests/fixtures/identical/expected.md new file mode 100644 index 0000000..7326256 --- /dev/null +++ b/scripts/kubernetes-hydrate/tests/fixtures/identical/expected.md @@ -0,0 +1,11 @@ +### Images + +No changes. + +### CustomResourceDefinitions + +No changes. + +### Resources + +No changes. diff --git a/scripts/kubernetes-hydrate/tests/fixtures/identical/manifest.yaml b/scripts/kubernetes-hydrate/tests/fixtures/identical/manifest.yaml new file mode 100644 index 0000000..c05d945 --- /dev/null +++ b/scripts/kubernetes-hydrate/tests/fixtures/identical/manifest.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: same diff --git a/scripts/kubernetes-hydrate/tests/fixtures/invalid/manifest.yaml b/scripts/kubernetes-hydrate/tests/fixtures/invalid/manifest.yaml new file mode 100644 index 0000000..c2b6f36 --- /dev/null +++ b/scripts/kubernetes-hydrate/tests/fixtures/invalid/manifest.yaml @@ -0,0 +1,2 @@ +kind: ConfigMap +metadata: [ diff --git a/scripts/kubernetes-hydrate/tests/fixtures/summarize/base.yaml b/scripts/kubernetes-hydrate/tests/fixtures/summarize/base.yaml new file mode 100644 index 0000000..69182fe --- /dev/null +++ b/scripts/kubernetes-hydrate/tests/fixtures/summarize/base.yaml @@ -0,0 +1,62 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: widgets.example.com +spec: + versions: + - name: v1alpha1 + served: true + storage: true +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: gadgets.example.com +spec: + versions: + - name: v1 + served: true + storage: true +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app + namespace: ns-a +spec: + template: + spec: + initContainers: + - name: init + image: busybox:1.36 + containers: + - name: app + image: registry.example.com/app:1.0 + args: + - --config-reloader=quay.io/example/reloader:v0.93.1 + - --log-level=info + env: + - name: HELPER_IMAGE + value: ghcr.io/example/helper:2.0 + - name: ENDPOINT + value: http://example.com/path:8080 +--- +apiVersion: monitoring.coreos.com/v1 +kind: Prometheus +metadata: + name: main + namespace: ns-a +spec: + image: quay.io/prometheus/prometheus:v3.14.0 + version: v3.14.0 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: old + namespace: ns-a +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: reader diff --git a/scripts/kubernetes-hydrate/tests/fixtures/summarize/expected.md b/scripts/kubernetes-hydrate/tests/fixtures/summarize/expected.md new file mode 100644 index 0000000..d46e01c --- /dev/null +++ b/scripts/kubernetes-hydrate/tests/fixtures/summarize/expected.md @@ -0,0 +1,29 @@ +### Images + +Collected from `image` fields and from other string values shaped like `registry/repository:tag` (args, env). + +| Repository | Base | Head | +|---|---|---| +| localhost:5000/sidecar | - | 0.1@sha256:abc | +| nginx | - | (untagged) | +| quay.io/example/reloader | v0.93.1 | v0.94.1 | +| quay.io/prometheus/prometheus | v3.14.0 | v3.15.0 | +| registry.example.com/app | 1.0 | 2.0 | + +### CustomResourceDefinitions + +`*` marks the storage version. + +| Name | Base | Head | +|---|---|---| +| gadgets.example.com | v1* | - | +| widgets.example.com | v1alpha1* | v1alpha1 (not served), v1* | + +### Resources + +Added: +- ConfigMap ns-a/new + +Removed: +- ConfigMap ns-a/old +- CustomResourceDefinition gadgets.example.com diff --git a/scripts/kubernetes-hydrate/tests/fixtures/summarize/head.yaml b/scripts/kubernetes-hydrate/tests/fixtures/summarize/head.yaml new file mode 100644 index 0000000..07a34d5 --- /dev/null +++ b/scripts/kubernetes-hydrate/tests/fixtures/summarize/head.yaml @@ -0,0 +1,63 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: widgets.example.com +spec: + versions: + - name: v1alpha1 + served: false + storage: false + - name: v1 + served: true + storage: true +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app + namespace: ns-a +spec: + template: + spec: + initContainers: + - name: init + image: busybox:1.36 + containers: + - name: app + image: registry.example.com/app:2.0 + args: + - --config-reloader=quay.io/example/reloader:v0.94.1 + - --log-level=info + env: + - name: HELPER_IMAGE + value: ghcr.io/example/helper:2.0 + - name: ENDPOINT + value: http://example.com/path:8080 + - name: sidecar + image: localhost:5000/sidecar:0.1@sha256:abc + - name: untagged + image: nginx +--- +--- +apiVersion: monitoring.coreos.com/v1 +kind: Prometheus +metadata: + name: main + namespace: ns-a +spec: + image: quay.io/prometheus/prometheus:v3.15.0 + version: v3.15.0 + thanos: + image: + repository: quay.io/thanos/thanos +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: new + namespace: ns-a +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: reader diff --git a/scripts/kubernetes-hydrate/tests/render-diff.test.sh b/scripts/kubernetes-hydrate/tests/render-diff.test.sh new file mode 100755 index 0000000..cc897ad --- /dev/null +++ b/scripts/kubernetes-hydrate/tests/render-diff.test.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +scripts="${here}/.." +work="$(mktemp -d)" +trap 'rm -rf "${work}"' EXIT +failures=0 + +pass() { echo "ok - $1"; } +fail() { echo "not ok - $1"; failures=$((failures + 1)); } +expect_contains() { if grep -qF -- "$2" "$3"; then pass "$1"; else fail "$1"; sed 's/^/# /' "$3"; fi; } +expect_absent() { if grep -qF -- "$2" "$3"; then fail "$1"; sed 's/^/# /' "$3"; else pass "$1"; fi; } + +write_deployment() { + local dir="$1" name="$2" image="$3" + mkdir -p "${dir}/kustomization" + printf 'resources:\n - deployment.yaml\n' > "${dir}/kustomization/kustomization.yaml" + cat > "${dir}/kustomization/deployment.yaml" < "${repo}/aws/eks/production/env.hcl" +write_deployment "${repo}/kubernetes/components/demo/production" demo nginx:1.0 +write_deployment "${repo}/kubernetes/components/gone/production" gone busybox:1.36 +write_deployment "${repo}/kubernetes/components/untouched/production" untouched redis:7 +touch "${repo}/kubernetes/components/demo/namespace.yaml" +git -C "${repo}" init -q -b main +git -C "${repo}" -c user.name=test -c user.email=test@example.com add -A +git -C "${repo}" -c user.name=test -c user.email=test@example.com commit -q -m base + +write_deployment "${repo}/kubernetes/components/demo/production" demo nginx:1.1 +echo "# changed" >> "${repo}/kubernetes/components/demo/namespace.yaml" +git -C "${repo}" rm -q -r kubernetes/components/gone +write_deployment "${repo}/kubernetes/components/pending-only/pending" pending-only busybox:1.36 +write_deployment "${repo}/kubernetes/components/broken/production" broken busybox:1.36 +printf 'resources:\n - missing.yaml\n' > "${repo}/kubernetes/components/broken/production/kustomization/kustomization.yaml" +git -C "${repo}" -c user.name=test -c user.email=test@example.com add -A +git -C "${repo}" -c user.name=test -c user.email=test@example.com commit -q -m head + +out="${work}/out" +(cd "${repo}" && bash "${scripts}/render-diff.sh" HEAD^ "${out}") > "${work}/stdout" 2>&1 || { fail "render-diff exits 0"; sed 's/^/# /' "${work}/stdout"; } +summary="${out}/summary.md" + +expect_contains "lists a changed component" "## demo (production)" "${summary}" +expect_contains "reports the changed image" "| nginx | 1.0 | 1.1 |" "${summary}" +expect_contains "links the full diff" "production/demo/manifest.diff" "${summary}" +expect_contains "writes the full diff" "+ - image: nginx:1.1" "${out}/production/demo/manifest.diff" +expect_contains "lists a deleted component" "## gone (production)" "${summary}" +expect_contains "reports resources of a deleted component as removed" "- Deployment gone" "${summary}" +expect_contains "reports a render failure instead of aborting" "Rendering failed (head)" "${summary}" +expect_contains "skips directories that are not hydrated environments" "Not rendered:" "${summary}" +if [ -d "${out}/pending" ]; then fail "does not render directories that are not hydrated environments"; else pass "does not render directories that are not hydrated environments"; fi +expect_absent "skips components without changes" "untouched" "${summary}" +if [ -z "$(git -C "${repo}" status --porcelain)" ] && [ "$(git -C "${repo}" worktree list | wc -l)" -eq 1 ]; then + pass "leaves the checkout and worktree list clean" +else + fail "leaves the checkout and worktree list clean" + git -C "${repo}" status --porcelain | sed 's/^/# /' +fi + +failing_bin="${work}/failing-bin" +mkdir -p "${failing_bin}" +printf '#!/bin/sh\nexit 1\n' > "${failing_bin}/yq" +chmod +x "${failing_bin}/yq" +(cd "${repo}" && PATH="${failing_bin}:${PATH}" bash "${scripts}/render-diff.sh" HEAD^ "${work}/out-failing") > /dev/null 2>&1 || fail "render-diff exits 0 when summarizing fails" +expect_contains "reports a summarizing failure instead of aborting" "Summarizing failed" "${work}/out-failing/summary.md" + +echo "# changed" >> "${repo}/README.md" +git -C "${repo}" -c user.name=test -c user.email=test@example.com add -A +git -C "${repo}" -c user.name=test -c user.email=test@example.com commit -q -m docs +(cd "${repo}" && bash "${scripts}/render-diff.sh" HEAD^ "${work}/out-none") > /dev/null 2>&1 || fail "render-diff exits 0 without components" +expect_contains "reports when no component changed" "No Kubernetes components changed." "${work}/out-none/summary.md" + +exit "${failures}" diff --git a/scripts/kubernetes-hydrate/tests/summarize-manifest-diff.test.sh b/scripts/kubernetes-hydrate/tests/summarize-manifest-diff.test.sh new file mode 100755 index 0000000..1d7d7c0 --- /dev/null +++ b/scripts/kubernetes-hydrate/tests/summarize-manifest-diff.test.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +summarize="${here}/../summarize-manifest-diff.sh" +fixtures="${here}/fixtures" +failures=0 + +check() { + local name="$1" base="$2" head="$3" expected="$4" actual + if actual="$(bash "${summarize}" "${base}" "${head}" 2>&1)" && diff -u "${expected}" <(printf '%s\n' "${actual}"); then + echo "ok - ${name}" + else + echo "not ok - ${name}" + printf '%s\n' "${actual}" | sed 's/^/# /' + failures=$((failures + 1)) + fi +} + +check "reports image, CRD and resource changes" \ + "${fixtures}/summarize/base.yaml" "${fixtures}/summarize/head.yaml" "${fixtures}/summarize/expected.md" +check "reports no changes for identical manifests" \ + "${fixtures}/identical/manifest.yaml" "${fixtures}/identical/manifest.yaml" "${fixtures}/identical/expected.md" + +for side in base head; do + if [ "${side}" = base ]; then args=("${fixtures}/invalid/manifest.yaml" "${fixtures}/identical/manifest.yaml"); else args=("${fixtures}/identical/manifest.yaml" "${fixtures}/invalid/manifest.yaml"); fi + if bash "${summarize}" "${args[@]}" > /dev/null 2>&1; then + echo "not ok - fails on an unparsable ${side} manifest" + failures=$((failures + 1)) + else + echo "ok - fails on an unparsable ${side} manifest" + fi +done + +exit "${failures}" diff --git a/scripts/release-please/find-configs.sh b/scripts/release-please/find-configs.sh new file mode 100755 index 0000000..6006f26 --- /dev/null +++ b/scripts/release-please/find-configs.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +git ls-files ':(glob)**/release-please-config.json' | + jq -R -s -c 'split("\n") | map(select(. != "") | sub("/?release-please-config\\.json$"; "") | if . == "" then "." else . end) | sort' diff --git a/scripts/release-please/find-configs.test.sh b/scripts/release-please/find-configs.test.sh new file mode 100755 index 0000000..b1396dd --- /dev/null +++ b/scripts/release-please/find-configs.test.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +find_configs="${script_dir}/find-configs.sh" +work="$(mktemp -d)" +trap 'rm -rf "${work}"' EXIT +failed=0 + +check() { + local name="$1" expected="$2" repo actual path + shift 2 + repo="$(mktemp -d "${work}/repo.XXXXXX")" + git -C "$repo" init -q + for path in "$@"; do + mkdir -p "${repo}/$(dirname "$path")" + echo '{}' >"${repo}/${path}" + done + git -C "$repo" add -A + actual="$(cd "$repo" && bash "$find_configs" 2>&1)" + if [ "$actual" = "$expected" ]; then + echo "PASS ${name}" + else + echo "FAIL ${name}" + echo " expected: ${expected}" + echo " actual: ${actual}" + failed=1 + fi +} + +test_finds_nested_configs() { + check test_finds_nested_configs '["a","b/c"]' a/release-please-config.json b/c/release-please-config.json b/c/other.json +} + +test_finds_root_config() { + check test_finds_root_config '["."]' release-please-config.json +} + +test_finds_none() { + check test_finds_none '[]' a/other.json +} + +test_finds_nested_configs +test_finds_root_config +test_finds_none + +exit "$failed" diff --git a/scripts/terragrunt-release/check-release-configs.sh b/scripts/terragrunt-release/check-release-configs.sh new file mode 100755 index 0000000..b9ca220 --- /dev/null +++ b/scripts/terragrunt-release/check-release-configs.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +config_path="${1:-workflow-config.yaml}" + +# Excluded targets still need a release-please config, so the check starts from the targets before exclusion. +targets="$(bash "${script_dir}/release-targets.sh" --all "${config_path}")" + +config="$(yq -o=json '.' "${config_path}")" + +failed=0 + +report() { + echo "::error::$*" >&2 + failed=1 +} + +incomplete="$(jq -r ' + .[] | select(.excluded | not) | . as $target + | ("aws_region", "iam_role_plan", "iam_role_apply") as $field + | select(($target[$field] // "") == "") + | "::error::release target \($target.working_directory) has no \($field)" +' <<<"${targets}")" +if [ -n "${incomplete}" ]; then + echo "${incomplete}" >&2 + failed=1 +fi + +# A package ends at {service} because what follows it differs per repository layout. +release_packages="$(jq -r ' + map(. as $target | [ + $target.service, + ($target.working_directory | split("/")[:($target.pattern | split("/") | index("{service}") + 1)] | join("/")) + ]) + | unique[] | @tsv +' <<<"${targets}")" + +while IFS=$'\t' read -r service package; do + [ -n "${service}" ] || continue + config_file="${package}/release-please-config.json" + manifest_file="${package}/release-please-manifest.json" + + if [ ! -f "${config_file}" ]; then + report "${config_file} is missing but ${service} has a release environment" + continue + fi + + component="$(jq -r --arg package "${package}" '.packages[$package].component // ""' "${config_file}")" + if [ "${component}" != "${service}" ]; then + report "${package}: component must be ${service} (got '${component}')" + fi + + if [ ! -f "${manifest_file}" ] || ! jq -e --arg package "${package}" 'has($package)' "${manifest_file}" >/dev/null; then + report "${manifest_file} must have the key ${package}" + fi +done <<<"${release_packages}" + +components="" +config_files="$(git ls-files '*/release-please-config.json')" +while IFS= read -r config_file; do + [ -n "${config_file}" ] || continue + packages="$(jq -r '.packages // {} | to_entries[] | [.key, (.value.component // "")] | @tsv' "${config_file}")" + while IFS=$'\t' read -r package component; do + [ -n "${package}" ] || continue + if [ -n "${component}" ]; then + components+="${component}"$'\t'"${package}"$'\n' + fi + expected_exclude_paths="$(jq -c --arg package "${package}" ' + ($package | split("/")) as $package_segments + | [ + .stacks[] | select(.name == "terragrunt") + | [.environments | to_entries[] | select(.value.apply_trigger != "release") | .key] as $environments + | .paths[] | select(contains("{service}") and contains("{environment}")) + | split("/") as $segments + | ($segments | index("{service}") + 1) as $depth + | select($depth == ($package_segments | length)) + | select([range($depth) | . as $i | ($segments[$i] | test("^\\{[a-z_][a-z0-9_]*\\}$")) or $segments[$i] == $package_segments[$i]] | all) + | [ + $environments[] as $environment + | [range($segments | length) | . as $i | if $i < $depth then $package_segments[$i] elif $segments[$i] == "{environment}" then $environment else $segments[$i] end] + | join("/") + ] + ] + | if length == 0 then null else add end + ' <<<"${config}")" + if [ "${expected_exclude_paths}" = "null" ]; then + continue + fi + actual_exclude_paths="$(jq -c --arg package "${package}" '.packages[$package]["exclude-paths"]' "${config_file}")" + if ! jq -ne --argjson expected "${expected_exclude_paths}" --argjson actual "${actual_exclude_paths}" ' + ($actual | type) == "array" and ($actual | sort) == ($expected | sort) + ' >/dev/null; then + report "${package}: exclude-paths must be ${expected_exclude_paths} (got ${actual_exclude_paths})" + fi + done <<<"${packages}" +done <<<"${config_files}" + +# A release tag names only the component, so a component shared by two packages would release both. +duplicates="$(jq -R -s -r ' + split("\n") | map(select(. != "") | split("\t")) | group_by(.[0])[] + | select(length > 1) + | "::error::component \(.[0][0]) is used by more than one package: \(map(.[1]) | join(" "))" +' <<<"${components}")" +if [ -n "${duplicates}" ]; then + echo "${duplicates}" >&2 + failed=1 +fi + +exit "${failed}" diff --git a/scripts/terragrunt-release/release-service.sh b/scripts/terragrunt-release/release-service.sh new file mode 100755 index 0000000..27ebbb9 --- /dev/null +++ b/scripts/terragrunt-release/release-service.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode="${1:-}" +value="${2:-}" + +case "${mode}" in + --tag) + pattern='^([a-z0-9-]+)-v[0-9]+\.[0-9]+\.[0-9]+$' + expected='-vX.Y.Z' + ;; + --branch) + pattern='^release-please--branches--main--components--([a-z0-9-]+)$' + expected='release-please--branches--main--components--' + ;; + *) + echo "::error::usage: release-service.sh --tag | --branch " >&2 + exit 1 + ;; +esac + +if [[ ! "${value}" =~ ${pattern} ]]; then + shown="${value//$'\n'/ }" + shown="${shown//$'\r'/ }" + echo "::error::'${shown}' does not match ${expected}" >&2 + exit 1 +fi + +echo "${BASH_REMATCH[1]}" diff --git a/scripts/terragrunt-release/release-targets.sh b/scripts/terragrunt-release/release-targets.sh new file mode 100755 index 0000000..86fdad3 --- /dev/null +++ b/scripts/terragrunt-release/release-targets.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +service="${1:?usage: release-targets.sh [config_path] | release-targets.sh --all [config_path]}" +config_path="${2:-workflow-config.yaml}" + +all=false +if [ "${service}" = "--all" ]; then + all=true +fi + +config="$(yq -o=json '.' "${config_path}")" + +# A path without {service} or {environment} cannot be tied to a release, so it never yields a target. +patterns="$(jq -r --arg service "${service}" --argjson all "${all}" ' + .stacks | to_entries[] + | select(.value.name == "terragrunt") + | .key as $stack + | .value as $value + | ($value.environments | to_entries[] | select(.value.apply_trigger == "release") | .key) as $environment + | $value.paths[] + | select(contains("{service}") and contains("{environment}")) + | [$stack, ., $environment, (gsub("\\{environment\\}"; $environment) | if $all then . else gsub("\\{service\\}"; $service) end)] + | @tsv +' <<<"${config}")" + +placeholder='^\{[a-z_][a-z0-9_]*\}$' +shopt -s nullglob + +# Walks one segment at a time so that a literal segment is never expanded as a glob. +find_directories() { + local segments segment dir child dirs="." next + IFS=/ read -r -a segments <<<"$1" + for segment in "${segments[@]}"; do + next="" + while IFS= read -r dir; do + [ -n "${dir}" ] || continue + if [[ "${segment}" =~ ${placeholder} ]]; then + for child in "${dir}"/*/; do + next+="${child%/}"$'\n' + done + elif [ -d "${dir}/${segment}" ]; then + next+="${dir}/${segment}"$'\n' + fi + done <<<"${dirs}" + dirs="${next}" + done + printf '%s' "${dirs}" +} + +matches="" +while IFS=$'\t' read -r stack pattern environment search; do + [ -n "${pattern}" ] || continue + while IFS= read -r directory; do + [ -n "${directory}" ] || continue + matches+="${stack}"$'\t'"${pattern}"$'\t'"${environment}"$'\t'"${directory#./}"$'\n' + done <<<"$(find_directories "${search}")" +done <<<"${patterns}" + +jq -R -s -c --argjson config "${config}" --argjson all "${all}" ' + def capture_regex: + "^" + (split("/") | map( + if test("^\\{[a-z_][a-z0-9_]*\\}$") then "(?<\(.[1:-1])>[^/]+)" + else gsub("(?[.^$|?*+()\\[\\]{}\\\\])"; "\\\(.c)") end + ) | join("/")) + "$"; + [ + split("\n")[] | select(. != "") | split("\t") + | . as [$stack, $pattern, $environment, $directory] + | $config.stacks[$stack | tonumber] as $stack_config + | $stack_config.environments[$environment] as $attributes + | ($directory | capture($pattern | capture_regex)) as $values + | { + service: $values.service, + environment: $environment, + working_directory: $directory, + aws_region: $attributes.aws_region, + iam_role_plan: $attributes.iam_role_plan, + iam_role_apply: $attributes.iam_role_apply, + pattern: $pattern, + excluded: any(($stack_config.exclude // [])[]; to_entries | all(.[]; . as $pair | $values | has($pair.key) and .[$pair.key] == $pair.value)) + } + ] + | if $all then . else map(select(.excluded | not) | del(.excluded, .pattern)) end +' <<<"${matches}" diff --git a/scripts/terragrunt-release/test.sh b/scripts/terragrunt-release/test.sh new file mode 100755 index 0000000..44072f5 --- /dev/null +++ b/scripts/terragrunt-release/test.sh @@ -0,0 +1,429 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +work_root="$(mktemp -d)" +trap 'rm -rf "${work_root}"' EXIT + +last_stderr="" + +assert_eq() { + local expected="$1" actual="$2" + if [ "${expected}" != "${actual}" ]; then + echo " expected: ${expected}" >&2 + echo " actual: ${actual}" >&2 + exit 1 + fi +} + +assert_fails() { + local stderr_file="${work_root}/stderr" + if "$@" 2>"${stderr_file}"; then + echo " expected a non-zero exit: $*" >&2 + exit 1 + fi + last_stderr="$(cat "${stderr_file}")" +} + +assert_stderr_contains() { + local needle="$1" + if [[ "${last_stderr}" != *"${needle}"* ]]; then + echo " stderr does not contain: ${needle}" >&2 + echo " stderr: ${last_stderr}" >&2 + exit 1 + fi +} + +test_validate_accepts_merge_and_release() { + cat > workflow-config.yaml <<'YAML' +stacks: + - name: terragrunt + environments: + develop: + apply_trigger: merge + production: + apply_trigger: release +YAML + bash "${script_dir}/validate-apply-trigger.sh" +} + +test_validate_rejects_missing_trigger() { + cat > workflow-config.yaml <<'YAML' +stacks: + - name: terragrunt + environments: + develop: + apply_trigger: merge + production: + aws_region: ap-northeast-1 +YAML + assert_fails bash "${script_dir}/validate-apply-trigger.sh" + assert_stderr_contains "environments.production.apply_trigger" +} + +test_validate_rejects_unknown_value() { + cat > workflow-config.yaml <<'YAML' +stacks: + - name: terragrunt + environments: + production: + apply_trigger: relase +YAML + assert_fails bash "${script_dir}/validate-apply-trigger.sh" + assert_stderr_contains "(got 'relase')" +} + +test_validate_ignores_non_terragrunt_stacks() { + cat > workflow-config.yaml <<'YAML' +stacks: + - name: container + paths: + - "dystopia/{service}" + - name: terragrunt + environments: + develop: + apply_trigger: merge + - name: kubernetes + paths: + - "dystopia/{service}/kubernetes/overlays/{environment}" +YAML + bash "${script_dir}/validate-apply-trigger.sh" +} + +test_release_service_parses_tag() { + assert_eq "monolith" "$(bash "${script_dir}/release-service.sh" --tag monolith-v1.2.3)" +} + +test_release_service_rejects_root_tag() { + assert_fails bash "${script_dir}/release-service.sh" --tag v0.1.0 + assert_stderr_contains "::error::" +} + +test_release_service_rejects_flux_tag() { + assert_fails bash "${script_dir}/release-service.sh" --tag kubernetes-production + assert_stderr_contains "::error::" +} + +test_release_service_parses_branch() { + assert_eq "pennyworth" "$(bash "${script_dir}/release-service.sh" --branch release-please--branches--main--components--pennyworth)" +} + +test_release_service_rejects_root_branch() { + assert_fails bash "${script_dir}/release-service.sh" --branch release-please--branches--main + assert_stderr_contains "::error::" +} + +test_release_service_error_is_single_line() { + assert_fails bash "${script_dir}/release-service.sh" --tag $'monolith-v1.2.3\n::warning::x' + assert_stderr_contains "::error::" + assert_eq "1" "$(printf '%s\n' "${last_stderr}" | wc -l | tr -d ' ')" +} + +write_release_config() { + local exclude="${1:-}" + cat > workflow-config.yaml <<'YAML' +stacks: + - name: container + paths: + - "dystopia/{service}" + - "system-components/{service}" + - name: terragrunt + paths: + - "{product}/{service}/infrastructure/aws/{environment}" + environments: + develop: + aws_region: ap-northeast-1 + iam_role_plan: arn:aws:iam::111111111111:role/develop-plan + iam_role_apply: arn:aws:iam::111111111111:role/develop-apply + apply_trigger: merge + production: + aws_region: ap-northeast-1 + iam_role_plan: arn:aws:iam::222222222222:role/production-plan + iam_role_apply: arn:aws:iam::222222222222:role/production-apply + apply_trigger: release + - name: kubernetes + paths: + - "{product}/{service}/kubernetes/overlays/{environment}" +YAML + if [ -n "${exclude}" ]; then + EXCLUDE="${exclude}" yq -i '(.stacks[] | select(.name == "terragrunt") | .exclude) = env(EXCLUDE)' workflow-config.yaml + fi +} + +test_release_targets_lists_release_envs() { + write_release_config + mkdir -p dystopia/monolith/infrastructure/aws/develop dystopia/monolith/infrastructure/aws/production + local expected='[{"service":"monolith","environment":"production","working_directory":"dystopia/monolith/infrastructure/aws/production","aws_region":"ap-northeast-1","iam_role_plan":"arn:aws:iam::222222222222:role/production-plan","iam_role_apply":"arn:aws:iam::222222222222:role/production-apply"}]' + local targets + targets="$(bash "${script_dir}/release-targets.sh" monolith)" + assert_eq "${expected}" "${targets}" +} + +test_release_targets_finds_other_products() { + write_release_config + mkdir -p system-components/pennyworth/infrastructure/aws/production dystopia/monolith/infrastructure/aws/production + local targets + targets="$(bash "${script_dir}/release-targets.sh" pennyworth)" + assert_eq "1" "$(jq 'length' <<<"${targets}")" + assert_eq "system-components/pennyworth/infrastructure/aws/production" "$(jq -r '.[0].working_directory' <<<"${targets}")" +} + +test_release_targets_returns_empty_without_dirs() { + write_release_config + mkdir -p dystopia/frontend + local targets + targets="$(bash "${script_dir}/release-targets.sh" frontend)" + assert_eq "[]" "${targets}" +} + +test_release_targets_skips_excluded_envs() { + write_release_config '[{environment: production}]' + mkdir -p dystopia/monolith/infrastructure/aws/production + local targets + targets="$(bash "${script_dir}/release-targets.sh" monolith)" + assert_eq "[]" "${targets}" +} + +test_release_targets_honors_product_scoped_exclude() { + write_release_config '[{product: system-components, environment: production}]' + mkdir -p system-components/pennyworth/infrastructure/aws/production dystopia/monolith/infrastructure/aws/production + local targets + targets="$(bash "${script_dir}/release-targets.sh" pennyworth)" + assert_eq "[]" "${targets}" + targets="$(bash "${script_dir}/release-targets.sh" monolith)" + assert_eq "dystopia/monolith/infrastructure/aws/production" "$(jq -r '.[0].working_directory' <<<"${targets}")" +} + +test_release_targets_all_keeps_excluded_targets() { + write_release_config '[{service: monolith}]' + mkdir -p dystopia/monolith/infrastructure/aws/production system-components/pennyworth/infrastructure/aws/production + local targets + targets="$(bash "${script_dir}/release-targets.sh" --all)" + assert_eq "monolith:true pennyworth:false" "$(jq -r 'map("\(.service):\(.excluded)") | sort | join(" ")' <<<"${targets}")" +} + +write_service_release_config() { + local package="$1" component="${2:-${1##*/}}" exclude_paths="${3:-[\"$1/infrastructure/aws/develop\"]}" + mkdir -p "${package}" + cat > "${package}/release-please-config.json" < "${package}/release-please-manifest.json" +} + +check_configs() { + git init -q + git add -A + bash "${script_dir}/check-release-configs.sh" "$@" +} + +test_check_configs_accepts_matching_set() { + write_release_config + mkdir -p dystopia/monolith/infrastructure/aws/production + write_service_release_config dystopia/monolith + write_service_release_config dystopia/frontend + assert_eq "" "$(check_configs 2>&1)" +} + +test_check_configs_rejects_missing_config() { + write_release_config + mkdir -p system-components/pennyworth/infrastructure/aws/production + assert_fails check_configs + assert_stderr_contains "system-components/pennyworth/release-please-config.json" +} + +test_check_configs_rejects_wrong_component() { + write_release_config + mkdir -p dystopia/monolith/infrastructure/aws/production + write_service_release_config dystopia/monolith frontend + assert_fails check_configs + assert_stderr_contains "dystopia/monolith: component must be monolith (got 'frontend')" +} + +test_check_configs_rejects_manifest_without_package() { + write_release_config + mkdir -p dystopia/monolith/infrastructure/aws/production + write_service_release_config dystopia/monolith + echo '{}' > dystopia/monolith/release-please-manifest.json + assert_fails check_configs + assert_stderr_contains "dystopia/monolith/release-please-manifest.json" + rm dystopia/monolith/release-please-manifest.json + assert_fails check_configs + assert_stderr_contains "dystopia/monolith/release-please-manifest.json" +} + +test_check_configs_rejects_incomplete_release_env() { + cat > workflow-config.yaml <<'YAML' +stacks: + - name: terragrunt + paths: + - "{product}/{service}/infrastructure/aws/{environment}" + environments: + production: + apply_trigger: release +YAML + mkdir -p dystopia/monolith/infrastructure/aws/production + write_service_release_config dystopia/monolith monolith '[]' + assert_fails check_configs + assert_stderr_contains "dystopia/monolith/infrastructure/aws/production" + assert_stderr_contains "aws_region" + assert_stderr_contains "iam_role_plan" + assert_stderr_contains "iam_role_apply" +} + +test_check_configs_rejects_missing_exclude_paths() { + write_release_config + mkdir -p dystopia/monolith/infrastructure/aws/production + write_service_release_config dystopia/monolith + jq -c 'del(.packages["dystopia/monolith"]["exclude-paths"])' dystopia/monolith/release-please-config.json > config.tmp + mv config.tmp dystopia/monolith/release-please-config.json + assert_fails check_configs + assert_stderr_contains '::error::dystopia/monolith: exclude-paths must be ["dystopia/monolith/infrastructure/aws/develop"] (got null)' +} + +test_check_configs_accepts_exclude_paths_in_any_order() { + write_release_config + yq -i '(.stacks[] | select(.name == "terragrunt") | .environments.staging) = {"apply_trigger": "merge"}' workflow-config.yaml + mkdir -p dystopia/monolith/infrastructure/aws/production + write_service_release_config dystopia/monolith monolith '["dystopia/monolith/infrastructure/aws/staging","dystopia/monolith/infrastructure/aws/develop"]' + assert_eq "" "$(check_configs 2>&1)" +} + +test_check_configs_checks_configs_without_infra() { + write_release_config + write_service_release_config dystopia/frontend frontend '["dystopia/frontend/develop"]' + assert_fails check_configs + assert_stderr_contains 'dystopia/frontend: exclude-paths must be ["dystopia/frontend/infrastructure/aws/develop"]' +} + +test_check_configs_requires_config_for_excluded_env() { + write_release_config '[{environment: production}]' + mkdir -p dystopia/monolith/infrastructure/aws/production + assert_fails check_configs + assert_stderr_contains "dystopia/monolith/release-please-config.json" + write_service_release_config dystopia/monolith + assert_eq "" "$(check_configs 2>&1)" +} + +test_check_configs_ignores_packages_outside_path_templates() { + write_release_config + write_service_release_config tools tools '[]' + write_service_release_config x/y/z z '[]' + assert_eq "" "$(check_configs 2>&1)" +} + +test_check_configs_ignores_untracked_configs() { + write_release_config + write_service_release_config dystopia/frontend + git init -q + git add -A + write_service_release_config node_modules/pkg pkg '[]' + assert_eq "" "$(bash "${script_dir}/check-release-configs.sh" 2>&1)" +} + +test_check_configs_rejects_duplicate_service() { + write_release_config + write_service_release_config dystopia/monolith + write_service_release_config system-components/monolith + assert_fails check_configs + assert_stderr_contains "component monolith is used by more than one package: dystopia/monolith system-components/monolith" +} + +write_service_root_release_config() { + cat > workflow-config.yaml <<'YAML' +stacks: + - name: terragrunt + paths: + - "aws/{service}/{environment}" + - "github/{service}/{environment}" + environments: + master: + aws_region: ap-northeast-1 + iam_role_plan: arn:aws:iam::111111111111:role/master-plan + iam_role_apply: arn:aws:iam::111111111111:role/master-apply + apply_trigger: merge + develop: + aws_region: ap-northeast-1 + iam_role_plan: arn:aws:iam::333333333333:role/develop-plan + iam_role_apply: arn:aws:iam::333333333333:role/develop-apply + apply_trigger: merge + production: + aws_region: ap-northeast-1 + iam_role_plan: arn:aws:iam::222222222222:role/production-plan + iam_role_apply: arn:aws:iam::222222222222:role/production-apply + apply_trigger: release +YAML + mkdir -p aws/vpc/master aws/vpc/develop aws/vpc/production +} + +test_check_release_configs_accepts_service_root_package() { + write_service_root_release_config + write_service_release_config aws/vpc vpc '["aws/vpc/master", "aws/vpc/develop"]' + assert_eq "" "$(check_configs 2>&1)" +} + +test_check_release_configs_rejects_missing_exclude_path() { + write_service_root_release_config + write_service_release_config aws/vpc vpc '["aws/vpc/master"]' + assert_fails check_configs + assert_stderr_contains "aws/vpc/develop" +} + +test_check_release_configs_accepts_product_service_package() { + write_release_config + write_service_release_config dystopia/frontend frontend '["dystopia/frontend/infrastructure/aws/develop"]' + assert_eq "" "$(check_configs 2>&1)" +} + +test_check_release_configs_derives_exclude_paths_from_the_matching_path_template() { + write_service_root_release_config + write_service_release_config aws/vpc vpc '["aws/vpc/master", "aws/vpc/develop"]' + write_service_release_config github/repository repository '[]' + assert_fails check_configs + assert_stderr_contains 'github/repository: exclude-paths must be ["github/repository/master","github/repository/develop"]' +} + +test_release_targets_lists_service_root_release_envs() { + write_service_root_release_config + local expected='[{"service":"vpc","environment":"production","working_directory":"aws/vpc/production","aws_region":"ap-northeast-1","iam_role_plan":"arn:aws:iam::222222222222:role/production-plan","iam_role_apply":"arn:aws:iam::222222222222:role/production-apply"}]' + assert_eq "${expected}" "$(bash "${script_dir}/release-targets.sh" vpc)" +} + +test_release_targets_all_reports_the_path_template() { + write_service_root_release_config + mkdir -p github/repository/production + local targets + targets="$(bash "${script_dir}/release-targets.sh" --all)" + assert_eq "aws/{service}/{environment} github/{service}/{environment}" "$(jq -r 'map(.pattern) | sort | join(" ")' <<<"${targets}")" +} + +test_validate_apply_trigger_rejects_merge_production() { + cat > workflow-config.yaml <<'YAML' +stacks: + - name: terragrunt + environments: + develop: + apply_trigger: merge + production: + apply_trigger: merge +YAML + assert_fails bash "${script_dir}/validate-apply-trigger.sh" + assert_stderr_contains "production" +} + +failed=0 +for test_name in $(compgen -A function test_); do + fixture="$(mktemp -d "${work_root}/fixture.XXXXXX")" + set +e + (set -e; cd "${fixture}"; "${test_name}") + status=$? + set -e + if [ "${status}" -eq 0 ]; then + echo "PASS: ${test_name}" + else + echo "FAIL: ${test_name}" + failed=1 + fi +done + +exit "${failed}" diff --git a/scripts/terragrunt-release/validate-apply-trigger.sh b/scripts/terragrunt-release/validate-apply-trigger.sh new file mode 100755 index 0000000..12c06fe --- /dev/null +++ b/scripts/terragrunt-release/validate-apply-trigger.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +config_path="${1:-workflow-config.yaml}" + +errors="$(yq -o=json '.' "${config_path}" | jq -r --arg q "'" ' + .stacks | to_entries[] + | select(.value.name == "terragrunt") + | .key as $index + | .value.environments | to_entries[] + | if .key == "production" and .value.apply_trigger != "release" then + "::error::stacks[\($index)].environments.production.apply_trigger must be release (got \($q)\(.value.apply_trigger)\($q))" + elif .value.apply_trigger | IN("merge", "release") | not then + "::error::stacks[\($index)].environments.\(.key).apply_trigger must be merge or release (got \($q)\(.value.apply_trigger)\($q))" + else + empty + end +')" + +if [ -n "${errors}" ]; then + echo "${errors}" >&2 + exit 1 +fi diff --git a/scripts/workflows/test.sh b/scripts/workflows/test.sh new file mode 100755 index 0000000..09467cf --- /dev/null +++ b/scripts/workflows/test.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(git -C "${script_dir}" rev-parse --show-toplevel)" +workflows_dir="${repo_root}/.github/workflows" + +assert_eq() { + local expected="$1" actual="$2" + if [ "${expected}" != "${actual}" ]; then + echo " expected: ${expected}" >&2 + echo " actual: ${actual}" >&2 + exit 1 + fi +} + +test_resolver_applies_only_merge_targets() { + local expected="\${{ matrix.target.stack == 'terragrunt' && (github.event_name == 'pull_request' && 'plan' || (matrix.target.apply_trigger == 'merge' && 'apply' || '')) || '' }}" + local actual + actual="$(yq '.jobs["deploy-terragrunt"].with["action-type"]' "${workflows_dir}/reusable--label-base-deploy--resolver.yaml")" + assert_eq "${expected}" "${actual}" +} + +test_release_workflows_skip_empty_targets() { + local guard="needs.resolve.outputs.targets != '[]'" + assert_eq "${guard}" "$(yq '.jobs.plan.if' "${workflows_dir}/reusable--release--plan.yaml")" + assert_eq "${guard}" "$(yq '.jobs.apply.if' "${workflows_dir}/reusable--release--deploy.yaml")" +} + +test_release_deploy_declares_environment() { + local workflow="${workflows_dir}/reusable--release--deploy.yaml" + assert_eq '${{ matrix.target.environment }}' "$(yq '.jobs.apply.with["github-environment"]' "${workflow}")" + assert_eq '${{ needs.resolve.outputs.tag }}' "$(yq '.jobs.apply.with.ref' "${workflow}")" +} + +test_kubernetes_builder_checks_out_head_ref_only_for_helmfile() { + local resolver="${workflows_dir}/reusable--label-base-deploy--resolver.yaml" + local builder="${workflows_dir}/reusable--kubernetes-builder.yaml" + assert_eq '${{ github.head_ref }}' "$(yq '.jobs["deploy-helmfile"].with.ref' "${resolver}")" + assert_eq 'null' "$(yq '.jobs["deploy-kustomize"].with.ref' "${resolver}")" + assert_eq '${{ inputs.ref }}' "$(yq '.jobs["kustomize-diff"].steps[] | select(.name == "Checkout") | .with.ref' "${builder}")" +} + +test_nested_test_workflow_uses_dollar_slash() { + local workflow="${workflows_dir}/test-shared-workflows--nested.yaml" + assert_eq '$/.github/workflows/test-shared-workflows--checkout.yaml' "$(yq '.jobs[].uses' "${workflow}")" +} + +failed=0 +for test_name in $(compgen -A function test_); do + set +e + (set -e; "${test_name}") + status=$? + set -e + if [ "${status}" -eq 0 ]; then + echo "PASS: ${test_name}" + else + echo "FAIL: ${test_name}" + failed=1 + fi +done + +exit "${failed}"