Skip to content

feat(capabilities): implement capability model and backend authorization enforcement (CYN-53) #119

feat(capabilities): implement capability model and backend authorization enforcement (CYN-53)

feat(capabilities): implement capability model and backend authorization enforcement (CYN-53) #119

Workflow file for this run

name: Pipeline
on:
pull_request:
types: [opened, synchronize, reopened, closed]
workflow_dispatch:
inputs:
dry_run:
description: "Only list matching environments without deleting"
type: boolean
default: true
pr_number_filter:
description: "Only delete environments for this PR number (empty = all)"
type: string
default: ""
concurrency:
group: pipeline-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event.action != 'closed' }}
permissions:
contents: read
checks: read
pull-requests: write
env:
DOTNET_CONFIGURATION: Release
DOTNET_NOLOGO: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
jobs:
check:
name: Format, lint, and test
runs-on: ubuntu-latest
if: github.event.action != 'closed'
steps:
- uses: actions/checkout@v4
- name: Setup .NET and restore
uses: ./.github/actions/setup-dotnet-and-restore
- name: Restore Cake runner
run: dotnet tool restore
- name: Format check
run: dotnet cake --target=FormatCheck
- name: Lint
run: dotnet cake --target=Lint --configuration=${{ env.DOTNET_CONFIGURATION }}
- name: Verify Docker
run: docker info
- name: Test
env:
TESTCONTAINERS_RYUK_DISABLED: "true"
run: dotnet cake --target=Test --configuration=${{ env.DOTNET_CONFIGURATION }}
neon-upsert:
name: Provision Neon branch and Render preview
runs-on: ubuntu-latest
if: github.event.action != 'closed'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Compute Neon branch name
id: name
uses: ./.github/actions/compute-neon-branch-name
with:
head_ref: ${{ github.head_ref }}
pr_number: ${{ github.event.number }}
- name: Create Neon branch
id: create
uses: neondatabase/create-branch-action@v5
with:
project_id: ${{ vars.NEON_PROJECT_ID }}
branch_name: ${{ steps.name.outputs.name }}
api_key: ${{ secrets.NEON_API_KEY }}
- name: Add render-preview label
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['render-preview'],
});
- name: Wait for Render preview service
id: preview
env:
RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }}
RENDER_SERVICE_BASE: cynara-api
PR_NUMBER: ${{ github.event.number }}
run: |
set -euo pipefail
base="https://api.render.com/v1"
auth_header="Authorization: Bearer ${RENDER_API_KEY}"
preview_name="${RENDER_SERVICE_BASE} PR #${PR_NUMBER}"
deadline=$(( $(date +%s) + 300 ))
service_id=""
while [ "$(date +%s)" -lt "$deadline" ]; do
response=$(curl -sS -G -w "\n%{http_code}" \
-H "$auth_header" \
-H "Accept: application/json" \
--data-urlencode "name=${preview_name}" \
--data "limit=20" \
--data "includePreviews=true" \
"${base}/services")
code=$(printf '%s' "$response" | tail -n1)
body=$(printf '%s' "$response" | sed '$d')
if [ "$code" != "200" ]; then
echo "Render API returned ${code}, retrying in 10s..."
sleep 10
continue
fi
service_id=$(printf '%s' "$body" \
| jq -r --arg n "${preview_name}" \
'.[] | select(.service.name == $n) | .service.id' \
| head -n1)
if [ -n "$service_id" ] && [ "$service_id" != "null" ]; then
break
fi
sleep 10
done
if [ -z "$service_id" ] || [ "$service_id" = "null" ]; then
echo "::error::Could not find Render preview service '${preview_name}' after 5 minutes."
echo "::error::If previews are configured with manual generation in the Render dashboard, this usually means Render did not pick up the render-preview label."
exit 1
fi
echo "service_id=${service_id}" >> "$GITHUB_OUTPUT"
- name: Write Neon connection string into Render preview
env:
RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }}
RENDER_SERVICE_ID: ${{ steps.preview.outputs.service_id }}
DB_URL: ${{ steps.create.outputs.db_url_with_pooler }}
run: |
set -euo pipefail
base="https://api.render.com/v1"
auth_header="Authorization: Bearer ${RENDER_API_KEY}"
CS=$(DB_URL="${DB_URL}" python3 - <<'PY'
import os, sys, urllib.parse as u
raw = os.environ["DB_URL"]
p = u.urlparse(raw)
qs = dict(u.parse_qsl(p.query, keep_blank_values=True))
def grab(*keys):
for k in keys:
if k in qs and qs[k] != "":
return qs[k]
return None
sslmode = (grab("sslmode", "ssl") or "require").lower()
if sslmode not in ("disable", "prefer", "require", "verify-ca", "verify-full"):
# Npgsql only accepts the three legacy values; neon uses `require`.
sslmode = "require"
channel = (grab("channel_binding") or "require").lower()
if channel not in ("disable", "prefer", "require"):
channel = "require"
password = u.unquote(p.password or "")
username = u.unquote(p.username or "")
database = (p.path or "").lstrip("/") or ""
host = p.hostname or ""
port = p.port or 5432
def q(v):
return "'" + str(v).replace("'", "''") + "'"
parts = [
f"Host={q(host)}",
f"Port={port}",
f"Database={q(database)}",
f"Username={q(username)}",
f"Password={q(password)}",
f"SSL Mode={sslmode}",
f"Channel Binding={channel}",
"Trust Server Certificate=true",
]
sys.stdout.write("; ".join(parts))
PY
)
curl -fsS -X PUT \
-H "$auth_header" \
-H "Content-Type: application/json" \
--data "$(jq -n --arg v "${CS}" '{value: $v}')" \
"${base}/services/${RENDER_SERVICE_ID}/env-vars/ConnectionStrings__Default"
- name: Comment on PR
uses: marocchino/sticky-pull-request-comment@v2
with:
header: neon-preview
message: Neon branch ready.
neon-delete:
name: Delete Neon branch and PR environment
runs-on: ubuntu-latest
if: github.event.action == 'closed'
permissions:
contents: read
deployments: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 1
- name: Compute Neon branch name
id: name
uses: ./.github/actions/compute-neon-branch-name
with:
head_ref: ${{ github.head_ref }}
pr_number: ${{ github.event.number }}
- name: Delete Neon branch
uses: neondatabase/delete-branch-action@v3
with:
project_id: ${{ vars.NEON_PROJECT_ID }}
branch: ${{ steps.name.outputs.name }}
api_key: ${{ secrets.NEON_API_KEY }}
cleanup-neon-environments:
name: Cleanup orphan Neon PR environments
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch'
permissions:
contents: read
steps:
- name: List and delete Neon PR environments
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ inputs.dry_run }}
PR_NUMBER_FILTER: ${{ inputs.pr_number_filter }}
run: |
set -euo pipefail
list_envs() {
local page=1
while :; do
local body status
body=$(mktemp)
status=$(curl -sS -o "$body" -w '%{http_code}' \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/environments?per_page=100&page=${page}")
if [ "$status" != "200" ]; then
echo "::error::Failed to list environments (status ${status})."
cat "$body"
exit 1
fi
jq -r '.environments[]?.name // empty' < "$body"
local count
count=$(jq -r '.environments | length // 0' < "$body")
rm -f "$body"
if [ "$count" -lt 100 ]; then
break
fi
page=$((page + 1))
done
}
names=$(list_envs)
pattern=' - cynara-api PR #'
if [ -n "${PR_NUMBER_FILTER}" ]; then
names=$(printf '%s\n' "$names" | grep -F "${pattern}${PR_NUMBER_FILTER}\$" || true)
else
names=$(printf '%s\n' "$names" | grep -F "$pattern" || true)
fi
if [ -z "$names" ]; then
echo "No matching Neon PR environments found."
exit 0
fi
echo "Matched environments:"
printf ' - %s\n' $names
if [ "${DRY_RUN}" = "true" ]; then
echo "::notice::Dry run — set inputs.dry_run=false to actually delete."
exit 0
fi
failures=0
while IFS= read -r name; do
[ -z "$name" ] && continue
encoded=$(jq -rn --arg n "$name" '$n | @uri')
body=$(mktemp)
status=$(curl -sS -o "$body" -w '%{http_code}' \
-X DELETE \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/environments/${encoded}")
case "$status" in
204|404)
echo "Removed: ${name} (status ${status})"
;;
*)
echo "::error::Failed to delete ${name} (status ${status})."
cat "$body"
failures=$((failures + 1))
;;
esac
rm -f "$body"
done <<< "$names"
if [ "$failures" -gt 0 ]; then
echo "::error::${failures} environment(s) failed to delete."
exit 1
fi