Add bundler-cache input to setup action #2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # kamal-previews / preview environment lifecycle. | ||
| # | ||
| # A reusable workflow that consumers call from their own repo with `uses:`. | ||
| # Triggers on the same events the consumer's workflow does — typically | ||
| # `pull_request` (open/sync/reopen/close) plus `delete` (branch). | ||
| # | ||
| # Internally it: | ||
| # 1. Checks out this repo at the same ref the consumer pinned, so the | ||
| # sub-actions (.github/actions/*) and helper scripts (scripts/*) are | ||
| # available locally. | ||
| # 2. Decides whether to deploy or tear down based on the triggering event. | ||
| # 3. Calls the matching sub-actions in sequence. | ||
| # | ||
| # Example consumer-side workflow: | ||
| # | ||
| # name: Preview environment | ||
| # on: | ||
| # pull_request: { types: [opened, synchronize, reopened, closed] } | ||
| # delete: | ||
| # permissions: | ||
| # contents: read | ||
| # packages: write | ||
| # pull-requests: write | ||
| # deployments: write | ||
| # jobs: | ||
| # preview: | ||
| # uses: webascender/kamal-previews/.github/workflows/preview.yml@v1 | ||
| # with: | ||
| # base-deploy-file: config/deploy.staging.yml | ||
| # domain-suffix: preview.example.com | ||
| # deploy-host: deploy.example.com | ||
| # database-engine: postgres | ||
| # database-template: myapp_staging | ||
| # database-name-pattern: "myapp_{db_slug}" | ||
| # secrets: inherit | ||
| name: kamal-previews / preview | ||
| on: | ||
| workflow_call: | ||
| inputs: | ||
| base-deploy-file: { type: string, required: true } | ||
| domain-suffix: { type: string, required: true } | ||
| deploy-host: { type: string, required: true } | ||
| database-engine: { type: string, default: none } | ||
| databases: | ||
| description: | | ||
| Multi-line list of databases (postgres/mysql). One entry per line: | ||
| `ENV_NAME=source_db:target_pattern`. Tokens in pattern: `{slug}`, | ||
| `{db_slug}`, `{base_database}` (= source). Each entry produces a | ||
| clone, an env.clear[ENV_NAME] entry with the resolved per-PR DB | ||
| name, a $ENV_NAME runner-env export, and a teardown drop. | ||
| type: string | ||
| default: "" | ||
| database-admin-url-var: | ||
| description: | | ||
| Variable name to read from `base-secrets-file` as the admin | ||
| connection URL. Default `DATABASE_URL`. Override with | ||
| `DATABASE_ADMIN_URL` secret if you need a different value than the | ||
| one your staging app connects with. | ||
| type: string | ||
| default: "DATABASE_URL" | ||
| sqlite-source-path: { type: string, default: "" } | ||
| sqlite-target-path-pattern:{ type: string, default: "" } | ||
| sqlite-also-clone: { type: string, default: "" } | ||
| base-secrets-file: { type: string, default: "" } | ||
| domain-label-pattern: { type: string, default: "{slug}" } | ||
| service-pattern: { type: string, default: "{base_service}-{slug}" } | ||
| destination-pattern: { type: string, default: "{slug}" } | ||
| env-label: { type: string, default: preview } | ||
| env-overrides: { type: string, default: "" } | ||
| env-secret-overrides: { type: string, default: "" } | ||
| deploy-timeout: { type: string, default: "" } | ||
| builder-context: { type: string, default: "" } | ||
| prefix-strip: { type: string, default: "feature/,feat/,fix/,bug/,bugfix/,chore/,hotfix/,release/" } | ||
| image-tag: { type: string, default: "" } | ||
| memory-limit: { type: string, default: "" } | ||
| cpu-limit: { type: string, default: "" } | ||
| branch-pattern: | ||
| description: | | ||
| Optional bash-glob pattern that the source branch name must match | ||
| to trigger a preview. Empty (default) = every PR gets a preview. | ||
| Examples: "feature/**", "@(feature|fix)/*", "release-*". | ||
| type: string | ||
| default: "" | ||
| max-active-previews: | ||
| description: | | ||
| Maximum number of active preview environments allowed. The deploy | ||
| step refuses to start a NEW preview when this many are already | ||
| active (an existing preview being re-deployed doesn't count | ||
| against the cap). 0 (default) = unlimited. | ||
| type: number | ||
| default: 0 | ||
| ruby-version: { type: string, default: "3.3" } | ||
| kamal-version: { type: string, default: "" } | ||
| bundler-cache: | ||
| description: Run `bundle install` (with caching) on the runner. Needed when `base-secrets-file` shells out to `bin/rails credentials:fetch`. | ||
| type: string | ||
| default: "false" | ||
| ssh-user: { type: string, default: deploy } | ||
| ssh-port: { type: string, default: "22" } | ||
| secrets: | ||
| SSH_PRIVATE_KEY: { required: true } | ||
| DATABASE_ADMIN_URL: { required: false } | ||
| RAILS_MASTER_KEY: { required: false } | ||
| KAMAL_REGISTRY_USERNAME: { required: false } | ||
| KAMAL_REGISTRY_PASSWORD: { required: false } | ||
| permissions: | ||
| contents: read | ||
| packages: write | ||
| pull-requests: write | ||
| deployments: write | ||
| id-token: write | ||
| # Concurrency group keyed by branch / PR / deleted ref. Cancel in-flight | ||
| # deploys (latest push wins) but never cancel teardowns. | ||
| concurrency: | ||
| group: kamal-previews-${{ github.event.pull_request.number || github.event.ref || github.ref }} | ||
| cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action != 'closed' }} | ||
| jobs: | ||
| # Cheap gating job: checks `branch-pattern` against the source branch. | ||
| # If empty pattern, always passes. Only the `deploy` job consults this — | ||
| # teardowns must run regardless of the pattern (otherwise enabling a | ||
| # pattern later would orphan previously-deployed previews). | ||
| eligibility: | ||
| if: github.event_name == 'pull_request' && github.event.action != 'closed' | ||
| runs-on: ubuntu-latest | ||
| outputs: | ||
| match: ${{ steps.filter.outputs.match }} | ||
| steps: | ||
| - id: filter | ||
| env: | ||
| BRANCH: ${{ github.head_ref || github.event.pull_request.head.ref }} | ||
| PATTERN: ${{ inputs.branch-pattern }} | ||
| run: | | ||
| set -euo pipefail | ||
| if [ -z "$PATTERN" ]; then | ||
| echo "match=true" >> "$GITHUB_OUTPUT" | ||
| exit 0 | ||
| fi | ||
| shopt -s extglob | ||
| # shellcheck disable=SC2053 | ||
| if [[ "$BRANCH" == $PATTERN ]]; then | ||
| echo "::notice::Branch '$BRANCH' matches preview pattern '$PATTERN'." | ||
| echo "match=true" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "::notice::Branch '$BRANCH' does not match preview pattern '$PATTERN' — skipping." | ||
| echo "match=false" >> "$GITHUB_OUTPUT" | ||
| fi | ||
| deploy: | ||
| needs: eligibility | ||
| if: | | ||
| github.event_name == 'pull_request' && github.event.action != 'closed' && | ||
| needs.eligibility.outputs.match == 'true' | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 30 | ||
| steps: | ||
| - name: Check out caller's repo | ||
| uses: actions/checkout@v4 | ||
| - name: Resolve kamal-previews ref | ||
| id: kp_ref | ||
| env: | ||
| WORKFLOW_REF: ${{ github.workflow_ref }} | ||
| # Parse `org/repo/.github/workflows/preview.yml@<ref>` — `ref` is the | ||
| # tag/branch/SHA the consumer pinned to in their `uses:` line. If the | ||
| # workflow is being run from a fork (rare for reusable workflows), | ||
| # this still resolves correctly. | ||
| run: | | ||
| set -euo pipefail | ||
| repo="${WORKFLOW_REF%/.github/workflows/*}" | ||
| ref="${WORKFLOW_REF##*@}" | ||
| echo "repo=$repo" >> "$GITHUB_OUTPUT" | ||
| echo "ref=$ref" >> "$GITHUB_OUTPUT" | ||
| echo "kamal-previews: repo=$repo ref=$ref" | ||
| - name: Check out kamal-previews (matching ref) | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| repository: ${{ steps.kp_ref.outputs.repo }} | ||
| ref: ${{ steps.kp_ref.outputs.ref }} | ||
| path: .kamal-previews | ||
| - uses: ./.kamal-previews/.github/actions/setup | ||
| with: | ||
| ruby-version: ${{ inputs.ruby-version }} | ||
| kamal-version: ${{ inputs.kamal-version }} | ||
| bundler-cache: ${{ inputs.bundler-cache }} | ||
| ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} | ||
| registry-username: ${{ secrets.KAMAL_REGISTRY_USERNAME }} | ||
| registry-password: ${{ secrets.KAMAL_REGISTRY_PASSWORD }} | ||
| - id: config | ||
| uses: ./.kamal-previews/.github/actions/generate-config | ||
| with: | ||
| branch-name: ${{ github.head_ref }} | ||
| base-deploy-file: ${{ inputs.base-deploy-file }} | ||
| base-secrets-file: ${{ inputs.base-secrets-file }} | ||
| domain-suffix: ${{ inputs.domain-suffix }} | ||
| domain-label-pattern: ${{ inputs.domain-label-pattern }} | ||
| service-pattern: ${{ inputs.service-pattern }} | ||
| destination-pattern: ${{ inputs.destination-pattern }} | ||
| databases: ${{ inputs.databases }} | ||
| image-tag: ${{ inputs.image-tag }} | ||
| env-label: ${{ inputs.env-label }} | ||
| env-overrides: ${{ inputs.env-overrides }} | ||
| env-secret-overrides: ${{ inputs.env-secret-overrides }} | ||
| deploy-timeout: ${{ inputs.deploy-timeout }} | ||
| builder-context: ${{ inputs.builder-context }} | ||
| memory-limit: ${{ inputs.memory-limit }} | ||
| cpu-limit: ${{ inputs.cpu-limit }} | ||
| prefix-strip: ${{ inputs.prefix-strip }} | ||
| - id: db_admin_url | ||
| if: inputs.database-engine == 'postgres' || inputs.database-engine == 'mysql' | ||
| env: | ||
| EXPLICIT_URL: ${{ secrets.DATABASE_ADMIN_URL }} | ||
| BASE_SECRETS_FILE: ${{ inputs.base-secrets-file }} | ||
| VAR_NAME: ${{ inputs.database-admin-url-var }} | ||
| RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} | ||
| run: | | ||
| set -euo pipefail | ||
| url="" | ||
| if [ -n "${EXPLICIT_URL:-}" ]; then | ||
| url="$EXPLICIT_URL" | ||
| elif [ -n "$BASE_SECRETS_FILE" ] && [ -f "$BASE_SECRETS_FILE" ]; then | ||
| url="$(set -a; . "$BASE_SECRETS_FILE"; set +a; printf '%s' "${!VAR_NAME-}")" | ||
| fi | ||
| if [ -z "$url" ]; then | ||
| echo "::error::Could not resolve a database admin URL. Set the DATABASE_ADMIN_URL secret or point base-secrets-file at a file that exposes \$${VAR_NAME}."; exit 1 | ||
| fi | ||
| echo "::add-mask::$url" | ||
| echo "KAMAL_PREVIEWS_DB_ADMIN_URL=$url" >> "$GITHUB_ENV" | ||
| - name: Enforce max-active-previews cap | ||
| if: inputs.max-active-previews > 0 | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| GH_REPO: ${{ github.repository }} | ||
| MAX_ACTIVE: ${{ inputs.max-active-previews }} | ||
| MY_ENV: preview-${{ steps.config.outputs.slug }} | ||
| PR_NUMBER: ${{ github.event.pull_request.number }} | ||
| run: | | ||
| set -euo pipefail | ||
| # List every deployment whose latest status is not inactive/removed | ||
| # for environments matching `preview-*`. We exclude our OWN env so a | ||
| # re-deploy of an existing preview never gets blocked by the cap. | ||
| mapfile -t active_envs < <( | ||
| gh api -H 'Accept: application/vnd.github+json' \ | ||
| "/repos/$GH_REPO/environments" --paginate \ | ||
| --jq '.environments[]?.name // .[]?.name' \ | ||
| | grep -E '^preview-' || true | ||
| ) | ||
| active_count=0 | ||
| for env in "${active_envs[@]}"; do | ||
| [ "$env" = "$MY_ENV" ] && continue | ||
| deployment_id="$(gh api -H 'Accept: application/vnd.github+json' \ | ||
| "/repos/$GH_REPO/deployments?environment=${env}&per_page=1" \ | ||
| --jq '.[0].id // empty' || true)" | ||
| [ -z "$deployment_id" ] && continue | ||
| latest_state="$(gh api -H 'Accept: application/vnd.github+json' \ | ||
| "/repos/$GH_REPO/deployments/${deployment_id}/statuses?per_page=1" \ | ||
| --jq '.[0].state // empty' || true)" | ||
| case "$latest_state" in | ||
| ""|inactive|removed|failure|error) ;; # not active, doesn't count | ||
| *) active_count=$((active_count + 1)) ;; | ||
| esac | ||
| done | ||
| echo "Active preview environments (excluding this one): $active_count / $MAX_ACTIVE cap" | ||
| if [ "$active_count" -ge "$MAX_ACTIVE" ]; then | ||
| msg="🚦 **Preview cap reached.** ${active_count} active preview environments are already running, which meets the cap of \`max-active-previews=${MAX_ACTIVE}\`. Close another preview PR (or wait for it to be torn down) and re-run this workflow." | ||
| if [ -n "${PR_NUMBER:-}" ]; then | ||
| gh api --method POST "/repos/$GH_REPO/issues/${PR_NUMBER}/comments" \ | ||
| -f body="<!-- kamal-previews:status --> | ||
| $msg" >/dev/null || true | ||
| fi | ||
| echo "::error::$msg" | ||
| exit 1 | ||
| fi | ||
| - id: sqlite_path | ||
| if: inputs.database-engine == 'sqlite' | ||
| env: | ||
| PATTERN: ${{ inputs.sqlite-target-path-pattern }} | ||
| SLUG: ${{ steps.config.outputs.slug }} | ||
| DB_SLUG: ${{ steps.config.outputs.db_slug }} | ||
| run: | | ||
| set -euo pipefail | ||
| if [ -z "$PATTERN" ]; then | ||
| echo "::error::sqlite-target-path-pattern is required when database-engine=sqlite" | ||
| exit 1 | ||
| fi | ||
| resolved="${PATTERN//\{slug\}/$SLUG}" | ||
| resolved="${resolved//\{db_slug\}/$DB_SLUG}" | ||
| echo "path=$resolved" >> "$GITHUB_OUTPUT" | ||
| - uses: ./.kamal-previews/.github/actions/deployment-status | ||
| with: | ||
| state: created | ||
| environment-name: preview-${{ steps.config.outputs.slug }} | ||
| - uses: ./.kamal-previews/.github/actions/pr-comment | ||
| with: | ||
| state: building | ||
| url: ${{ steps.config.outputs.url }} | ||
| destination: ${{ steps.config.outputs.destination }} | ||
| database-name: ${{ steps.config.outputs.database_name }} | ||
| - if: inputs.database-engine != 'none' | ||
| uses: ./.kamal-previews/.github/actions/clone-database | ||
| with: | ||
| engine: ${{ inputs.database-engine }} | ||
| host: ${{ inputs.deploy-host }} | ||
| ssh-user: ${{ inputs.ssh-user }} | ||
| ssh-port: ${{ inputs.ssh-port }} | ||
| admin-url: ${{ env.KAMAL_PREVIEWS_DB_ADMIN_URL }} | ||
| databases: ${{ steps.config.outputs.databases_resolved }} | ||
| sqlite-source-path: ${{ inputs.sqlite-source-path }} | ||
| sqlite-target-path: ${{ steps.sqlite_path.outputs.path }} | ||
| sqlite-also-clone: ${{ inputs.sqlite-also-clone }} | ||
| - uses: ./.kamal-previews/.github/actions/deploy | ||
| with: | ||
| destination: ${{ steps.config.outputs.destination }} | ||
| - if: success() | ||
| uses: ./.kamal-previews/.github/actions/deployment-status | ||
| with: | ||
| state: success | ||
| environment-name: preview-${{ steps.config.outputs.slug }} | ||
| url: ${{ steps.config.outputs.url }} | ||
| - if: success() | ||
| uses: ./.kamal-previews/.github/actions/pr-comment | ||
| with: | ||
| state: ready | ||
| url: ${{ steps.config.outputs.url }} | ||
| destination: ${{ steps.config.outputs.destination }} | ||
| database-name: ${{ steps.config.outputs.database_name }} | ||
| - if: failure() | ||
| uses: ./.kamal-previews/.github/actions/deployment-status | ||
| with: | ||
| state: failure | ||
| environment-name: preview-${{ steps.config.outputs.slug }} | ||
| - if: failure() | ||
| uses: ./.kamal-previews/.github/actions/pr-comment | ||
| with: | ||
| state: failed | ||
| destination: ${{ steps.config.outputs.destination }} | ||
| database-name: ${{ steps.config.outputs.database_name }} | ||
| teardown: | ||
| if: | | ||
| (github.event_name == 'pull_request' && github.event.action == 'closed') || | ||
| (github.event_name == 'delete' && github.event.ref_type == 'branch') | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 15 | ||
| steps: | ||
| - name: Check out caller's repo | ||
| uses: actions/checkout@v4 | ||
| - name: Resolve kamal-previews ref | ||
| id: kp_ref | ||
| env: | ||
| WORKFLOW_REF: ${{ github.workflow_ref }} | ||
| run: | | ||
| set -euo pipefail | ||
| repo="${WORKFLOW_REF%/.github/workflows/*}" | ||
| ref="${WORKFLOW_REF##*@}" | ||
| echo "repo=$repo" >> "$GITHUB_OUTPUT" | ||
| echo "ref=$ref" >> "$GITHUB_OUTPUT" | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| repository: ${{ steps.kp_ref.outputs.repo }} | ||
| ref: ${{ steps.kp_ref.outputs.ref }} | ||
| path: .kamal-previews | ||
| - uses: ./.kamal-previews/.github/actions/setup | ||
| with: | ||
| ruby-version: ${{ inputs.ruby-version }} | ||
| kamal-version: ${{ inputs.kamal-version }} | ||
| bundler-cache: ${{ inputs.bundler-cache }} | ||
| ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} | ||
| setup-buildx: "false" | ||
| setup-gha-cache: "false" | ||
| - id: branch | ||
| env: | ||
| PR_BRANCH: ${{ github.event.pull_request.head.ref }} | ||
| DELETED_REF: ${{ github.event.ref }} | ||
| run: | | ||
| set -euo pipefail | ||
| name="${PR_BRANCH:-$DELETED_REF}" | ||
| if [ -z "$name" ]; then | ||
| echo "::error::Could not determine branch name from event payload." | ||
| exit 1 | ||
| fi | ||
| echo "name=$name" >> "$GITHUB_OUTPUT" | ||
| - id: config | ||
| uses: ./.kamal-previews/.github/actions/generate-config | ||
| with: | ||
| branch-name: ${{ steps.branch.outputs.name }} | ||
| base-deploy-file: ${{ inputs.base-deploy-file }} | ||
| base-secrets-file: ${{ inputs.base-secrets-file }} | ||
| domain-suffix: ${{ inputs.domain-suffix }} | ||
| domain-label-pattern: ${{ inputs.domain-label-pattern }} | ||
| service-pattern: ${{ inputs.service-pattern }} | ||
| destination-pattern: ${{ inputs.destination-pattern }} | ||
| databases: ${{ inputs.databases }} | ||
| env-label: ${{ inputs.env-label }} | ||
| prefix-strip: ${{ inputs.prefix-strip }} | ||
| - id: db_admin_url | ||
| if: inputs.database-engine == 'postgres' || inputs.database-engine == 'mysql' | ||
| env: | ||
| EXPLICIT_URL: ${{ secrets.DATABASE_ADMIN_URL }} | ||
| BASE_SECRETS_FILE: ${{ inputs.base-secrets-file }} | ||
| VAR_NAME: ${{ inputs.database-admin-url-var }} | ||
| RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} | ||
| run: | | ||
| set -euo pipefail | ||
| url="" | ||
| if [ -n "${EXPLICIT_URL:-}" ]; then | ||
| url="$EXPLICIT_URL" | ||
| elif [ -n "$BASE_SECRETS_FILE" ] && [ -f "$BASE_SECRETS_FILE" ]; then | ||
| url="$(set -a; . "$BASE_SECRETS_FILE"; set +a; printf '%s' "${!VAR_NAME-}")" | ||
| fi | ||
| if [ -z "$url" ]; then | ||
| echo "::error::Could not resolve a database admin URL for teardown."; exit 1 | ||
| fi | ||
| echo "::add-mask::$url" | ||
| echo "KAMAL_PREVIEWS_DB_ADMIN_URL=$url" >> "$GITHUB_ENV" | ||
| - id: sqlite_path | ||
| if: inputs.database-engine == 'sqlite' | ||
| env: | ||
| PATTERN: ${{ inputs.sqlite-target-path-pattern }} | ||
| SLUG: ${{ steps.config.outputs.slug }} | ||
| DB_SLUG: ${{ steps.config.outputs.db_slug }} | ||
| run: | | ||
| set -euo pipefail | ||
| if [ -z "$PATTERN" ]; then | ||
| echo "path=" >> "$GITHUB_OUTPUT" | ||
| exit 0 | ||
| fi | ||
| resolved="${PATTERN//\{slug\}/$SLUG}" | ||
| resolved="${resolved//\{db_slug\}/$DB_SLUG}" | ||
| echo "path=$resolved" >> "$GITHUB_OUTPUT" | ||
| - uses: ./.kamal-previews/.github/actions/teardown | ||
| with: | ||
| destination: ${{ steps.config.outputs.destination }} | ||
| - if: inputs.database-engine != 'none' | ||
| uses: ./.kamal-previews/.github/actions/drop-database | ||
| with: | ||
| engine: ${{ inputs.database-engine }} | ||
| host: ${{ inputs.deploy-host }} | ||
| ssh-user: ${{ inputs.ssh-user }} | ||
| ssh-port: ${{ inputs.ssh-port }} | ||
| admin-url: ${{ env.KAMAL_PREVIEWS_DB_ADMIN_URL }} | ||
| databases: ${{ steps.config.outputs.databases_resolved }} | ||
| sqlite-target-path: ${{ steps.sqlite_path.outputs.path }} | ||
| sqlite-also-drop: ${{ inputs.sqlite-also-clone }} | ||
| - if: always() | ||
| uses: ./.kamal-previews/.github/actions/deployment-status | ||
| with: | ||
| state: removed | ||
| environment-name: preview-${{ steps.config.outputs.slug }} | ||
| - if: always() && github.event_name == 'pull_request' | ||
| uses: ./.kamal-previews/.github/actions/pr-comment | ||
| with: | ||
| state: removed | ||
| destination: ${{ steps.config.outputs.destination }} | ||
| database-name: ${{ steps.config.outputs.database_name }} | ||
| pr-number: ${{ github.event.pull_request.number }} | ||