diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..696c10b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,76 @@ +name: Deploy + +on: + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + id-token: write + +concurrency: + group: deploy-production + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + env: + GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }} + GCP_REGION: ${{ vars.GCP_REGION || 'us-central1' }} + GCP_FUNCTION_NAME: ${{ vars.GCP_FUNCTION_NAME || 'progress' }} + GCP_RUNTIME: ${{ vars.GCP_RUNTIME || 'go124' }} + GCP_ENTRY_POINT: ${{ vars.GCP_ENTRY_POINT || 'Progress' }} + GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + GCP_SERVICE_ACCOUNT: ${{ vars.GCP_SERVICE_ACCOUNT }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate deploy configuration + run: | + set -euo pipefail + required_vars=( + GCP_PROJECT_ID + GCP_WORKLOAD_IDENTITY_PROVIDER + GCP_SERVICE_ACCOUNT + ) + + for var in "${required_vars[@]}"; do + if [[ -z "${!var:-}" ]]; then + echo "Missing required repository variable: $var" + exit 1 + fi + done + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ env.GCP_SERVICE_ACCOUNT }} + project_id: ${{ env.GCP_PROJECT_ID }} + + - name: Deploy Cloud Function + id: deploy + uses: google-github-actions/deploy-cloud-functions@v4 + with: + name: ${{ env.GCP_FUNCTION_NAME }} + project_id: ${{ env.GCP_PROJECT_ID }} + region: ${{ env.GCP_REGION }} + runtime: ${{ env.GCP_RUNTIME }} + entry_point: ${{ env.GCP_ENTRY_POINT }} + environment: GEN_2 + source_dir: ./ + all_traffic_on_latest_revision: true + + - name: Smoke test deployed endpoint + env: + BASE_URL: ${{ steps.deploy.outputs.url }} + PROGRESS_PATH: "" + run: bash ./scripts/smoke.sh + + - name: Print deployed URL + run: echo "Deployed URL: ${{ steps.deploy.outputs.url }}" diff --git a/README.md b/README.md index 7bd96fc..bcc229c 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,13 @@ BASE_URL=https://geps.dev mise exec -- make smoke The smoke test validates status codes, headers, and basic content contract. +If your `BASE_URL` already includes the function path (for example +`https://REGION-PROJECT.cloudfunctions.net/progress`), set: + +```bash +BASE_URL=https://REGION-PROJECT.cloudfunctions.net/progress PROGRESS_PATH="" mise exec -- make smoke +``` + ## Deploy (Google Cloud) Set your project first: @@ -135,10 +142,110 @@ After deploy, run smoke tests: BASE_URL=https://YOUR_DOMAIN_OR_FUNCTION_URL mise exec -- make smoke ``` +## Automated Deploy (GitHub Actions -> GCP) + +This repo includes `.github/workflows/deploy.yml` to deploy automatically on +push to `master` (and manually via `workflow_dispatch`). + +### 1) Configure GitHub repository variables + +Required: + +- `GCP_PROJECT_ID` (example: `progress-markdown`) +- `GCP_WORKLOAD_IDENTITY_PROVIDER` (full resource name) +- `GCP_SERVICE_ACCOUNT` (deployer service account email) + +Optional (defaults are already set in workflow): + +- `GCP_REGION` (`us-central1`) +- `GCP_FUNCTION_NAME` (`progress`) +- `GCP_RUNTIME` (`go124`) +- `GCP_ENTRY_POINT` (`Progress`) + +### 2) One-time GCP setup (OIDC/WIF, no JSON keys) + +Create deployer service account: + +```bash +gcloud iam service-accounts create github-deployer \ + --display-name "GitHub deployer for markdown-progress" +``` + +Grant deploy permissions on project: + +```bash +PROJECT_ID=progress-markdown +PROJECT_NUMBER=$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)') +SA="github-deployer@${PROJECT_ID}.iam.gserviceaccount.com" + +gcloud projects add-iam-policy-binding "$PROJECT_ID" \ + --member="serviceAccount:${SA}" \ + --role="roles/cloudfunctions.developer" + +gcloud projects add-iam-policy-binding "$PROJECT_ID" \ + --member="serviceAccount:${SA}" \ + --role="roles/run.admin" + +gcloud projects add-iam-policy-binding "$PROJECT_ID" \ + --member="serviceAccount:${SA}" \ + --role="roles/artifactregistry.writer" + +gcloud projects add-iam-policy-binding "$PROJECT_ID" \ + --member="serviceAccount:${SA}" \ + --role="roles/cloudbuild.builds.editor" + +gcloud projects add-iam-policy-binding "$PROJECT_ID" \ + --member="serviceAccount:${SA}" \ + --role="roles/iam.serviceAccountUser" +``` + +Create Workload Identity Pool + Provider: + +```bash +PROJECT_ID=progress-markdown +PROJECT_NUMBER=$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)') +POOL_ID=github +PROVIDER_ID=github-oidc + +gcloud iam workload-identity-pools create "$POOL_ID" \ + --project="$PROJECT_ID" \ + --location="global" \ + --display-name="GitHub Actions Pool" + +gcloud iam workload-identity-pools providers create-oidc "$PROVIDER_ID" \ + --project="$PROJECT_ID" \ + --location="global" \ + --workload-identity-pool="$POOL_ID" \ + --display-name="GitHub Actions Provider" \ + --issuer-uri="https://token.actions.githubusercontent.com" \ + --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref" +``` + +Allow only this repository to impersonate the deployer service account: + +```bash +PROJECT_ID=progress-markdown +PROJECT_NUMBER=$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)') +POOL_ID=github +REPO="gepser/markdown-progress" +SA="github-deployer@${PROJECT_ID}.iam.gserviceaccount.com" + +gcloud iam service-accounts add-iam-policy-binding "$SA" \ + --role="roles/iam.workloadIdentityUser" \ + --member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}/attribute.repository/${REPO}" +``` + +Provider resource name to set in GitHub variable: + +```text +projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID +``` + ## CI - `CI` workflow runs `go test` and `go vet` on pushes and PRs. - `Smoke Tests` workflow can be run manually (`workflow_dispatch`) with a `base_url` input. +- `Deploy` workflow deploys to GCP on `master` using OIDC/WIF. ## Contributing diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 0d5e012..7805a59 100644 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -7,6 +7,21 @@ if [[ -z "${BASE_URL:-}" ]]; then fi BASE_URL="${BASE_URL%/}" +PROGRESS_PATH="${PROGRESS_PATH:-/progress}" + +if [[ -n "$PROGRESS_PATH" && "$PROGRESS_PATH" != /* ]]; then + PROGRESS_PATH="/${PROGRESS_PATH}" +fi + +progress_url() { + local suffix="$1" + if [[ -z "$PROGRESS_PATH" ]]; then + echo "${BASE_URL}${suffix}" + return + fi + + echo "${BASE_URL}${PROGRESS_PATH}${suffix}" +} expect_status() { local expected="$1" @@ -60,16 +75,16 @@ expect_body_contains() { echo "Running smoke tests against ${BASE_URL}" -expect_status "200" "GET" "${BASE_URL}/progress/76" -expect_header_contains "${BASE_URL}/progress/76" "Content-Type" "image/svg+xml" -expect_header_contains "${BASE_URL}/progress/76" "Cache-Control" "max-age=300" -expect_body_contains "${BASE_URL}/progress/76" "76%" +expect_status "200" "GET" "$(progress_url "/76")" +expect_header_contains "$(progress_url "/76")" "Content-Type" "image/svg+xml" +expect_header_contains "$(progress_url "/76")" "Cache-Control" "max-age=300" +expect_body_contains "$(progress_url "/76")" "76%" -expect_status "400" "GET" "${BASE_URL}/progress/not-a-number" -expect_status "400" "GET" "${BASE_URL}/progress/50?successColor=nothex" -expect_status "405" "POST" "${BASE_URL}/progress/50" +expect_status "400" "GET" "$(progress_url "/not-a-number")" +expect_status "400" "GET" "$(progress_url "/50?successColor=nothex")" +expect_status "405" "POST" "$(progress_url "/50")" -expect_status "200" "GET" "${BASE_URL}/progress/150" -expect_body_contains "${BASE_URL}/progress/150" "100%" +expect_status "200" "GET" "$(progress_url "/150")" +expect_body_contains "$(progress_url "/150")" "100%" echo "Smoke tests passed."