Skip to content

Add bundler-cache input to setup action #2

Add bundler-cache input to setup action

Add bundler-cache input to setup action #2

Workflow file for this run

# 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: "" }

Check failure on line 66 in .github/workflows/preview.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/preview.yml

Invalid workflow file

You have an error in your yaml syntax on line 66
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 }}