From 1cf8fac57ed6fe456e711e336dc368ac2c51d438 Mon Sep 17 00:00:00 2001 From: Fabian Date: Mon, 15 Jun 2026 17:37:30 +0100 Subject: [PATCH 1/6] feat: per-family drift Slack notifications (autogen stage 1c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single truncated drift-report blob with one summary message plus one message per drifting family, posted individually to a generic {title,body} Slack Workflow Builder trigger: - summary: drift counts (zero categories dropped) + scaffoldable families + a run link; - one per new endpoint family: paths + a link/CLI to dispatch the stage-2 scaffold workflow for that family; - one per partial family with unclaimed operations: the lagging ops, flagged as needing a manual PR (stage 2 only scaffolds new resources). No tool change — driftreport already emits new_families / unclaimed_operations / status_counts in `-format json` and preserves exit-2-on-drift. Public-repo safety preserved: family names / paths / operationIds go only to the private Slack channel (passed to curl, never echoed); step summary stays yes/no. Requires the SLACK_DRIFT_WEBHOOK_URL Workflow Builder trigger to declare two Text variables `title` and `body` (was message/repo/run_url/report). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/drift-report.yml | 131 ++++++++++++++++++++--------- 1 file changed, 91 insertions(+), 40 deletions(-) diff --git a/.github/workflows/drift-report.yml b/.github/workflows/drift-report.yml index abb3625b..05656759 100644 --- a/.github/workflows/drift-report.yml +++ b/.github/workflows/drift-report.yml @@ -3,9 +3,21 @@ # # Diffs the endpoint families of LaunchDarkly's public OpenAPI spec against the # resources registered in the provider, using the curated mapping at -# scripts/driftreport/mapping.yaml. On drift, posts to a private Slack channel +# scripts/driftreport/mapping.yaml. On drift, posts to a PRIVATE Slack channel # via the SLACK_DRIFT_WEBHOOK_URL secret. This repo is PUBLIC, so internal -# drift tracking must not use GitHub issues. +# drift tracking must not use GitHub issues, artifacts, or step-summary detail. +# +# NOTIFICATION MODEL (one Slack message becomes several): +# 1. a summary message (drift counts per category), then +# 2. one message per NEW endpoint family, each carrying the family's paths and +# a one-click link + CLI to dispatch the stage-2 scaffold workflow for it, +# 3. one message per partial family with UNCLAIMED operations (informational — +# stage 2 only scaffolds NEW resources, so these need a manual PR). +# Messages are POSTed individually to a SINGLE generic Slack Workflow Builder +# webhook trigger. That trigger must declare exactly two Text variables — +# `title` and `body` — and a "Send a message" step into the private channel. +# (`body` is Slack mrkdwn: links render.) The stage-3 verify +# workflow reuses the same trigger for its "ready for review" message. name: API drift report on: @@ -42,7 +54,10 @@ jobs: id: drift run: | set +e - /tmp/driftreport -out drift-report.md + # JSON (not md): the notify step builds Slack messages from the + # structured report. The file stays on the ephemeral runner and is + # never uploaded or printed — it carries the internal roadmap. + /tmp/driftreport -format json -out drift-report.json code=$? set -e if [ "$code" -ne 0 ] && [ "$code" -ne 2 ]; then @@ -51,46 +66,82 @@ jobs: fi drift=$([ "$code" -eq 2 ] && echo true || echo false) echo "drift=$drift" >> "$GITHUB_OUTPUT" - # PUBLIC-REPO SAFETY: this run's logs/summary are world-readable, so the - # report body must NOT land here. Summary states only whether drift was - # found; the detail goes to the private Slack channel. drift-report.md - # stays on the ephemeral runner (never uploaded as an artifact). + # PUBLIC-REPO SAFETY: this run's logs/summary are world-readable, so + # only the yes/no fact lands here. Detail goes to the private channel. if [ "$drift" = "true" ]; then - echo "Drift detected — full report sent to the private Slack channel." >> "$GITHUB_STEP_SUMMARY" + echo "Drift detected — detail sent to the private Slack channel." >> "$GITHUB_STEP_SUMMARY" else echo "No drift detected." >> "$GITHUB_STEP_SUMMARY" fi - # Build the Slack payload with jq so the report markdown is correctly - # JSON-escaped (quotes/newlines/backticks can't break the payload). - # Truncated to stay under Slack's ~4000-char webhook-variable limit; - # full detail is one re-dispatch away. - - name: Build Slack payload - id: slack_payload - if: steps.drift.outputs.drift == 'true' - run: | - limit=3500 - report="$(head -c "$limit" drift-report.md)" - if [ "$(wc -c < drift-report.md)" -gt "$limit" ]; then - report="${report}"$'\n\n…(truncated — re-dispatch the workflow for the full report)' - fi - payload="$(jq -nc \ - --arg message "API coverage drift detected — the provider lags behind the public API. Review and decide whether to scaffold." \ - --arg repo "$GITHUB_REPOSITORY" \ - --arg run_url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ - --arg report "$report" \ - '{message: $message, repo: $repo, run_url: $run_url, report: $report}')" - { - printf '%s\n' 'payload<> "$GITHUB_OUTPUT" - # Notify a private Slack channel on drift. The Workflow Builder trigger - # behind SLACK_DRIFT_WEBHOOK_URL should declare these Text variables: - # message, repo, run_url, report. + # Post one summary + one-per-family message to the private Slack channel. + # Each POST carries {title, body}; the Workflow Builder trigger renders + # them. PUBLIC-REPO SAFETY: family names / paths / operationIds are the + # internal roadmap — they are passed only to curl, NEVER echoed to the log. - name: Notify Slack on drift if: steps.drift.outputs.drift == 'true' - uses: slackapi/slack-github-action@485a9d42d3a73031f12ec201c457e2162c45d02d # v2.0.0 - with: - webhook: ${{ secrets.SLACK_DRIFT_WEBHOOK_URL }} - webhook-type: webhook-trigger - payload: ${{ steps.slack_payload.outputs.payload }} + shell: bash + env: + WEBHOOK: ${{ secrets.SLACK_DRIFT_WEBHOOK_URL }} + REPO: ${{ github.repository }} + SERVER: ${{ github.server_url }} + RUN_ID: ${{ github.run_id }} + run: | + JSON=drift-report.json + RUN_URL="$SERVER/$REPO/actions/runs/$RUN_ID" + ACTIONS_URL="$SERVER/$REPO/actions/workflows/scaffold-resource.yml" + LIMIT=3500 + sent=0 + + # post_slack <body>: POST one message to the Workflow Builder + # trigger. Truncates body to Slack's webhook-variable limit. Never + # echoes title/body (roadmap) to the world-readable log. + post_slack() { + local title="$1" body="$2" payload + if [ "${#body}" -gt "$LIMIT" ]; then + body="${body:0:$LIMIT}"$'\n\n…(truncated — re-dispatch for full detail)' + fi + payload="$(jq -nc --arg title "$title" --arg body "$body" '{title: $title, body: $body}')" + curl -sS -f --retry 3 --retry-connrefused --retry-delay 2 \ + -X POST -H 'Content-Type: application/json' -d "$payload" "$WEBHOOK" >/dev/null + sent=$((sent + 1)) + } + + # 1) Summary — one tally line (zero categories dropped) + scaffoldable + # families + run link. The title carries the headline, so the body + # doesn't restate it. + summary_body="$(jq -r --arg run "$RUN_URL" ' + def lbl($n; $one; $many): if $n > 0 then "\($n) \(if $n == 1 then $one else $many end)" else empty end; + ([ lbl(.new_families | length; "new family"; "new families"), + lbl(.unclaimed_operations | length; "unclaimed op"; "unclaimed ops"), + lbl(.stale_families | length; "stale family"; "stale families"), + lbl(.stale_operations | length; "stale op claim"; "stale op claims"), + lbl(.unmapped_resources | length; "unmapped resource"; "unmapped resources") + ] | join(" · ")) + + (if (.new_families | length) > 0 then "\nScaffoldable: " + ([.new_families[].tag] | join(", ")) else "" end) + + "\n<\($run)|drift run>" + ' "$JSON")" + post_slack "🔎 API coverage drift" "$summary_body" + + # 2) One message per NEW family — scaffoldable via stage 2. Paths inline, + # scaffold link + CLI on one line. + while IFS= read -r fam; do + [ -n "$fam" ] || continue + tag="$(printf '%s' "$fam" | jq -r '.tag')" + paths="$(printf '%s' "$fam" | jq -r '[.paths[] | "`" + . + "`"] | join(" · ")')" + # shellcheck disable=SC2016 # backticks are literal Slack mrkdwn, not command subst + body="$(printf 'No provider resource yet. Paths: %s\n<%s|▶ Scaffold it> · `gh workflow run scaffold-resource.yml -f family='"'"'%s'"'"'`' \ + "$paths" "$ACTIONS_URL" "$tag")" + post_slack "🆕 New API family: $tag" "$body" + done < <(jq -c '.new_families[]' "$JSON") + + # 3) One message per partial family with UNCLAIMED ops — informational + # (stage 2 only scaffolds NEW resources, so no dispatch link). + while IFS= read -r tag; do + [ -n "$tag" ] || continue + ops="$(jq -r --arg t "$tag" '.unclaimed_operations[] | select(.tag == $t) | "• `\(.method) \(.path)` (`\(.operation_id)`)"' "$JSON")" + # shellcheck disable=SC2016 # backticks are literal Slack mrkdwn, not command subst + body="$(printf 'Modeled, but lagging these ops — extend the existing resource via a manual PR:\n%s' "$ops")" + post_slack "⚠️ Coverage gap: $tag" "$body" + done < <(jq -r '[.unclaimed_operations[].tag] | unique | .[]' "$JSON") + + echo "Posted $sent Slack message(s)." From 996c4c99dcec96a77ad5a34311eb88ff232cd416 Mon Sep 17 00:00:00 2001 From: Fabian <ffeldberg@launchdarkly.com> Date: Mon, 15 Jun 2026 17:37:30 +0100 Subject: [PATCH 2/6] feat: scaffolded-resource verification agent (autogen stage 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add verify-scaffold.yml: a workflow_dispatch(pr_number) job that verifies a stage-2 scaffolded draft PR against a real LaunchDarkly account before a human finishes it. Flow: guard the PR (same-repo, scaffold/* branch, open, integer pr_number) → check out the PR branch → fetch the LD token from SSM via AWS OIDC (the same path the acceptance matrix uses; api_host forced to prod) → build the provider to a pinned GOBIN with a terraform dev override → run claude-code-action to review the code and exercise the new resource with real terraform plan/apply, fixing functionality/tests until apply is clean. Applied example resources are retained (no destroy), namespaced tf-verify-pr<N> and pre-cleaned per run. Results post as a PR comment plus a "ready for review" message on the same {title,body} Slack trigger as the drift report. Gated by repository variable VERIFY_AGENT_ENABLED. Security: prereq cleanup is a deterministic workflow step so the agent needs no curl with the live token; git tooling is scoped to the needed subcommands; runs are serialized per PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .github/workflows/verify-scaffold.yml | 297 ++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 .github/workflows/verify-scaffold.yml diff --git a/.github/workflows/verify-scaffold.yml b/.github/workflows/verify-scaffold.yml new file mode 100644 index 00000000..c5548a75 --- /dev/null +++ b/.github/workflows/verify-scaffold.yml @@ -0,0 +1,297 @@ +# Stage 3 of the autogen pipeline (see .claude/plans/AUTOGEN_PIPELINE.md): +# verify a stage-2 scaffolded draft PR against a REAL LaunchDarkly account +# before a human finishes it. Picks up the draft PR, builds the provider from +# its branch, points terraform at the local build via a dev override, then runs +# an agent that reviews the code and exercises the new resource with real +# `terraform plan`/`apply`, fixing functionality/tests until apply is clean. +# +# The applied example resources are RETAINED (no `terraform destroy`) so a human +# reviewer can inspect them in the LD account; they are namespaced by PR number +# (project key `tf-verify-pr<N>`) and overwritten on each run, so at most one +# set lingers per PR. Result posts back as a PR comment AND a private-Slack +# "ready for review" message (same generic title/body webhook as the drift +# report — SLACK_DRIFT_WEBHOOK_URL). +# +# This file lives on `main` only so GitHub registers the workflow_dispatch +# trigger. It checks out the PR's own branch (always a same-repo scaffold/* +# branch off the v3 line), so no ref pin is needed here. +# +# Credentials: the LD access token comes from the same AWS-OIDC -> SSM path the +# acceptance matrix uses (a prod REST token); api_host is forced to prod. +# Kill switch: repository variable VERIFY_AGENT_ENABLED must be "true". +name: Verify scaffolded resource against LaunchDarkly + +on: + workflow_dispatch: + inputs: + pr_number: + description: "Number of the stage-2 draft PR to verify (branch must be scaffold/*)" + required: true + type: string + notes: + description: "Optional extra instructions for the verification agent" + required: false + type: string + +permissions: + contents: write # agent pushes fixes to the PR branch + pull-requests: write # agent posts a review comment + id-token: write # AWS OIDC to read the LD token from SSM + +jobs: + verify: + name: Verify PR #${{ inputs.pr_number }} + runs-on: ubuntu-latest + timeout-minutes: 90 + # Serialize runs per PR so two dispatches don't clobber each other's apply + # / branch push / the shared tf-verify-pr<N> project. Queue, don't cancel. + concurrency: + group: verify-scaffold-pr-${{ inputs.pr_number }} + cancel-in-progress: false + env: + # Pin the install dir so the workflow's `go install` and the agent's later + # rebuilds land in the SAME place the dev override points at — no GOPATH + # drift between subshells (cf. memory feedback_dev_override_gopath). + GOBIN: ${{ github.workspace }}/.gobin + steps: + - name: Check kill switch + if: vars.VERIFY_AGENT_ENABLED != 'true' + run: | + echo "::error::Verification agent is disabled (repository variable VERIFY_AGENT_ENABLED != 'true')." + exit 1 + + # Resolve the PR to its head branch and guard the inputs: same-repo only + # (fork branches must not run with our OIDC/secrets) and scaffold/* only + # (this workflow is for stage-2 output, not arbitrary PRs). + - name: Resolve PR + id: pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ inputs.pr_number }} + REPO: ${{ github.repository }} + run: | + if ! printf '%s' "$PR" | grep -Eq '^[0-9]+$'; then + echo "::error::pr_number must be a positive integer (got '$PR')." + exit 1 + fi + meta="$(gh pr view "$PR" --repo "$REPO" --json headRefName,isCrossRepository,baseRefName,title,url,state)" + cross="$(printf '%s' "$meta" | jq -r '.isCrossRepository')" + head="$(printf '%s' "$meta" | jq -r '.headRefName')" + state="$(printf '%s' "$meta" | jq -r '.state')" + url="$(printf '%s' "$meta" | jq -r '.url')" + title="$(printf '%s' "$meta" | jq -r '.title')" + if [ "$cross" = "true" ]; then + echo "::error::PR #$PR is from a fork; verification runs real applies with our credentials and only supports same-repo PRs." + exit 1 + fi + if [ "$state" != "OPEN" ]; then + echo "::error::PR #$PR is not open (state=$state)." + exit 1 + fi + case "$head" in + scaffold/*) : ;; + *) + echo "::error::PR #$PR head branch '$head' is not a scaffold/* branch." + exit 1 + ;; + esac + { + echo "head_branch=$head" + echo "pr_url=$url" + echo "pr_title=$title" + } >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ steps.pr.outputs.head_branch }} + fetch-depth: 0 + # Keep credentials so the agent can push fixes back to the branch. + persist-credentials: true + + - uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0 + with: + audience: https://github.com/launchdarkly + aws-region: us-east-1 + role-to-assume: arn:aws:iam::061661829416:role/github-actions-terraform-provider-launchdarkly + role-session-name: GitHubActionsVerify_run-${{ github.run_id }} + - id: get-launchdarkly-access-token + uses: dkershner6/aws-ssm-getparameters-action@4fcb4872421f387a6c43058473acc1b22443fe13 # v2.0.3 + with: + parameterPairs: | + /global/services/github/terraform-provider/launchdarkly-access-token = LAUNCHDARKLY_ACCESS_TOKEN + - name: Mask the LD token + run: echo "::add-mask::$LAUNCHDARKLY_ACCESS_TOKEN" + + # setup-go AFTER checkout so go-version-file reads the PR branch's go.mod + # (the v3 line pins a newer Go than main). + - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 + with: + go-version-file: "go.mod" + cache: true + - run: go mod download + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + terraform_wrapper: false + + # Build the provider from the PR branch and point terraform at it via a + # dev override, so `terraform plan`/`apply` exercise THIS code with no + # `terraform init` and no registry download. + - name: Build provider and configure dev override + run: | + # `go install` (not `make build`) so a cosmetic gofmt issue — caught + # separately by the PR's own CI — can't block verification. GOBIN (job + # env) fixes the install dir; the dev override points at the same dir. + mkdir -p "$GOBIN" + go install . + echo "Provider installed to $GOBIN" + cat > "$HOME/.terraformrc" <<EOF + provider_installation { + dev_overrides { + "launchdarkly/launchdarkly" = "$GOBIN" + } + direct {} + } + EOF + + # Deterministic prereq cleanup (NOT delegated to the agent): delete any + # leftover project from a previous run of this PR so `apply` starts clean. + # Doing this here — not in the agent — means the agent does not need + # Bash(curl:*) with the live admin token (removes an exfiltration path). + - name: Pre-clean prior verification project + env: + LD_TOKEN: ${{ env.LAUNCHDARKLY_ACCESS_TOKEN }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + code="$(curl -sS -X DELETE \ + -H "Authorization: $LD_TOKEN" \ + "https://app.launchdarkly.com/api/v2/projects/tf-verify-pr$PR_NUMBER" \ + -o /dev/null -w '%{http_code}' || true)" + # 200/204 = deleted, 404 = nothing to clean; anything else is non-fatal + # (the apply will surface a real problem). Don't print bodies. + echo "pre-clean DELETE tf-verify-pr$PR_NUMBER -> HTTP $code" + + - name: Run verification agent + uses: anthropics/claude-code-action@0f97b95b6536c26e5f6bd90faec370d41695beca # v1 + env: + # LAUNCHDARKLY_ACCESS_TOKEN is already in the job process env (the SSM + # action exported it via GITHUB_ENV, masked) and is inherited by the + # agent's shell — same pattern test.yml uses for `make testacc`. Force + # api_host to prod (the token is a prod REST token; the shell default + # may be staging). The provider treats access_token as sensitive. + LAUNCHDARKLY_API_HOST: https://app.launchdarkly.com + TF_IN_AUTOMATION: "1" + PR_NUMBER: ${{ inputs.pr_number }} + PR_URL: ${{ steps.pr.outputs.pr_url }} + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + prompt: | + You are stage 3 (verification) of the LaunchDarkly Terraform provider + autogen pipeline. A stage-2 agent scaffolded a NEW resource on the + currently checked-out branch and opened draft PR #${{ inputs.pr_number }} + (${{ steps.pr.outputs.pr_title }}). Verify it against a REAL LaunchDarkly + account before a human finishes it. + + Environment already prepared for you: + - The provider is built from this branch and installed to $GOBIN. + - ~/.terraformrc has a dev_override for "launchdarkly/launchdarkly" pointing + there, so `terraform plan`/`apply` run THIS build with NO `terraform init`. + - Real LD credentials are in the environment: LAUNCHDARKLY_ACCESS_TOKEN and + LAUNCHDARKLY_API_HOST=https://app.launchdarkly.com. NEVER print the token. + - `terraform` is on PATH. + - A previous `tf-verify-pr${{ inputs.pr_number }}` project (if any) was already + deleted by a workflow step, so the account is clean for a project-scoped apply. + + Do this, in order: + + 1. REVIEW the scaffolded code on this branch. Run `git fetch origin preview-v3` + first, then `git diff origin/preview-v3...HEAD` to see only the scaffold, + for correctness against CLAUDE.md and the vendored playbook at + .claude/skills/terraform-provider-add-resource/ (SKILL.md + references/). + Note real defects (schema/CRUD/helper/test/docs issues), not style nits. + + 2. EXERCISE the resource. Write a minimal terraform config under a temp dir + (e.g. "$RUNNER_TEMP/verify") with a `provider "launchdarkly" {}` block + (it reads creds from the environment), a prerequisite project keyed + `tf-verify-pr${{ inputs.pr_number }}` (use this key so the live resources are + identifiable and the workflow can pre-clean it next run), and the new resource + (plus its data source if one was scaffolded). If the new resource is + account-scoped rather than project-scoped, namespace its key/name with + `tf-verify-pr${{ inputs.pr_number }}` instead. Then: + terraform -chdir="$RUNNER_TEMP/verify" plan -input=false + terraform -chdir="$RUNNER_TEMP/verify" apply -input=false -auto-approve + A second `plan` afterwards MUST be empty (no perpetual diff). Do NOT commit + the scratch config or any terraform.tfstate*. + + 3. FIX as needed. If plan/apply or the empty-diff check fails, fix the resource + implementation/tests/docs on the branch, re-run `go install .` (the dev + override auto-picks up the rebuilt binary), and retry. Where practical, also run the + resource's acceptance test: + TF_ACC=1 TF_ACC_TERRAFORM_PATH="$(command -v terraform)" \ + go test -run <TestAccName> ./launchdarkly/... -v -timeout 30m + Run `make fmt` and `go build ./...` before committing. + + 4. RETAIN the resources. Do NOT run `terraform destroy`. Leave the applied + example resources in the account for human review. + + 5. COMMIT any fixes to this branch (clear conventional-commit message; touch only + files relevant to the fix — never the scratch config or state) and push. + + 6. REPORT. Post a PR comment on #${{ inputs.pr_number }}. This repo is PUBLIC, so + keep the comment to: the verification verdict (did plan/apply succeed? was the + re-plan clean? which tests ran?), any code fixes you made, and the retained + project key `tf-verify-pr${{ inputs.pr_number }}`. Do NOT restate API endpoint + paths / operationIds / roadmap detail beyond what the diff already shows, and + note the LD console link is in the private Slack thread. Use: + gh pr comment ${{ inputs.pr_number }} --body-file <file> + + 7. Write a machine-readable result to `verify-result.json` at the repo root (do NOT + commit it) with exactly these fields: + {"status":"pass"|"fail","project_key":"tf-verify-pr${{ inputs.pr_number }}", + "ld_url":"https://app.launchdarkly.com/projects/tf-verify-pr${{ inputs.pr_number }}", + "summary":"<one or two sentences: what you verified and any fixes>"} + status is "pass" only if apply succeeded AND the re-plan was clean. + + Operator notes: ${{ inputs.notes }} + # No Bash(curl:*): prereq cleanup is a workflow step, so the agent never + # needs raw HTTP with the live admin token (closes an exfil path). git is + # scoped to the subcommands the flow needs (no `git config` token read). + claude_args: | + --allowedTools "Read,Edit,Write,Glob,Grep,Bash(go:*),Bash(make:*),Bash(git fetch:*),Bash(git diff:*),Bash(git log:*),Bash(git status:*),Bash(git add:*),Bash(git commit:*),Bash(git push:*),Bash(gh pr comment:*),Bash(gh pr view:*),Bash(gofmt:*),Bash(terraform:*),WebFetch(domain:app.launchdarkly.com)" + + # Deterministic, log-safe Slack notification. Reads the agent's result file + # if present; otherwise reports the run errored (no internal detail leaks). + - name: Notify Slack + # Only notify once we got past the input guards (so a disabled kill + # switch or a rejected PR doesn't ping the channel). Runs whether the + # agent step passed or failed. + if: always() && steps.pr.outcome == 'success' + shell: bash + env: + WEBHOOK: ${{ secrets.SLACK_DRIFT_WEBHOOK_URL }} + PR_URL: ${{ steps.pr.outputs.pr_url }} + PR_NUMBER: ${{ inputs.pr_number }} + REPO: ${{ github.repository }} + SERVER: ${{ github.server_url }} + RUN_ID: ${{ github.run_id }} + run: | + RUN_URL="$SERVER/$REPO/actions/runs/$RUN_ID" + if [ -f verify-result.json ] && jq -e . verify-result.json >/dev/null 2>&1; then + status="$(jq -r '.status // "unknown"' verify-result.json)" + ld_url="$(jq -r '.ld_url // ""' verify-result.json)" + summary="$(jq -r '.summary // ""' verify-result.json)" + project="$(jq -r '.project_key // ""' verify-result.json)" + if [ "$status" = "pass" ]; then icon="✅"; else icon="⚠️"; fi + title="$icon Scaffold PR #$PR_NUMBER verified ($status)" + # shellcheck disable=SC2016 # backticks are literal Slack mrkdwn, not command subst + body="$(printf '%s\nRetained in `%s`: <%s|open in LaunchDarkly> · <%s|PR #%s>' \ + "$summary" "$project" "$ld_url" "$PR_URL" "$PR_NUMBER")" + else + title="❌ Scaffold PR #$PR_NUMBER verification did not complete" + body="$(printf 'Stage-3 verification produced no result file — the run likely errored. See the <%s|workflow run> and <%s|PR #%s>.' \ + "$RUN_URL" "$PR_URL" "$PR_NUMBER")" + fi + payload="$(jq -nc --arg title "$title" --arg body "$body" '{title: $title, body: $body}')" + curl -sS -f --retry 3 --retry-connrefused --retry-delay 2 \ + -X POST -H 'Content-Type: application/json' -d "$payload" "$WEBHOOK" >/dev/null + echo "Posted verification result to Slack." From 9fb6b457b7fe5772216459bb9fd91d7f552ef593 Mon Sep 17 00:00:00 2001 From: Fabian <ffeldberg@launchdarkly.com> Date: Tue, 16 Jun 2026 11:03:29 +0100 Subject: [PATCH 3/6] feat: surface scaffoldable resources + scoped stage-2 scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire stage 1d (#458, merged to preview-v3) into the workflows on main. drift-report.yml: the summary tally now counts scaffoldable_resources and registered_candidates and lists candidate names alongside new families; a new per-candidate message announces each curated net-new resource in a partial family with a SCOPED stage-2 dispatch command (family + resource_name + operations). scaffold-resource.yml: new optional resource_name + operations dispatch inputs. In scoped mode the agent implements exactly one net-new resource covering the listed operations, branches/PRs under that name, and moves the ops out of the family's new_resource_candidates into a resources entry (the family stays partial) — explicitly NOT touching the family's existing resources. Whole-family mode is unchanged. verify-scaffold.yml needs no change: a scaffold/<resource_name> branch still matches its scaffold/* guard. actionlint + YAML clean; the notification loop was dry-run against synthetic scaffoldable_resources output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .github/workflows/drift-report.yml | 30 ++++++++++++++++++++----- .github/workflows/scaffold-resource.yml | 28 ++++++++++++++--------- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/.github/workflows/drift-report.yml b/.github/workflows/drift-report.yml index 05656759..0af55194 100644 --- a/.github/workflows/drift-report.yml +++ b/.github/workflows/drift-report.yml @@ -111,13 +111,16 @@ jobs: # doesn't restate it. summary_body="$(jq -r --arg run "$RUN_URL" ' def lbl($n; $one; $many): if $n > 0 then "\($n) \(if $n == 1 then $one else $many end)" else empty end; - ([ lbl(.new_families | length; "new family"; "new families"), - lbl(.unclaimed_operations | length; "unclaimed op"; "unclaimed ops"), - lbl(.stale_families | length; "stale family"; "stale families"), - lbl(.stale_operations | length; "stale op claim"; "stale op claims"), - lbl(.unmapped_resources | length; "unmapped resource"; "unmapped resources") + ([ lbl(.new_families | length; "new family"; "new families"), + lbl(.scaffoldable_resources | length; "scaffoldable resource"; "scaffoldable resources"), + lbl(.unclaimed_operations | length; "unclaimed op"; "unclaimed ops"), + lbl(.stale_families | length; "stale family"; "stale families"), + lbl(.stale_operations | length; "stale op claim"; "stale op claims"), + lbl(.unmapped_resources | length; "unmapped resource"; "unmapped resources"), + lbl(.registered_candidates | length; "miscurated candidate"; "miscurated candidates") ] | join(" · ")) + - (if (.new_families | length) > 0 then "\nScaffoldable: " + ([.new_families[].tag] | join(", ")) else "" end) + + (([.new_families[].tag] + [.scaffoldable_resources[].name]) as $s + | if ($s | length) > 0 then "\nScaffoldable: " + ($s | join(", ")) else "" end) + "\n<\($run)|drift run>" ' "$JSON")" post_slack "🔎 API coverage drift" "$summary_body" @@ -134,6 +137,21 @@ jobs: post_slack "🆕 New API family: $tag" "$body" done < <(jq -c '.new_families[]' "$JSON") + # 2b) One message per curated NET-NEW resource inside a partial family — + # scaffoldable via stage 2, scoped to the resource name + its operations + # (the family's existing resources are untouched). + while IFS= read -r sr; do + [ -n "$sr" ] || continue + tag="$(printf '%s' "$sr" | jq -r '.tag')" + name="$(printf '%s' "$sr" | jq -r '.name')" + opids="$(printf '%s' "$sr" | jq -r '[.operations[].operation_id] | join(",")')" + oplist="$(printf '%s' "$sr" | jq -r '[.operations[] | "• `\(.method) \(.path)`"] | join("\n")')" + # shellcheck disable=SC2016 # backticks are literal Slack mrkdwn, not command subst + body="$(printf 'Net-new resource in the `%s` family (existing resources untouched):\n%s\n<%s|▶ Scaffold it> · `gh workflow run scaffold-resource.yml -f family='"'"'%s'"'"' -f resource_name='"'"'%s'"'"' -f operations='"'"'%s'"'"'`' \ + "$tag" "$oplist" "$ACTIONS_URL" "$tag" "$name" "$opids")" + post_slack "🆕 New resource: $name" "$body" + done < <(jq -c '.scaffoldable_resources[]' "$JSON") + # 3) One message per partial family with UNCLAIMED ops — informational # (stage 2 only scaffolds NEW resources, so no dispatch link). while IFS= read -r tag; do diff --git a/.github/workflows/scaffold-resource.yml b/.github/workflows/scaffold-resource.yml index 6ce9c8d9..b9c1aeeb 100644 --- a/.github/workflows/scaffold-resource.yml +++ b/.github/workflows/scaffold-resource.yml @@ -23,6 +23,14 @@ on: description: "Endpoint family tag from the drift report (e.g. AgentControl)" required: true type: string + resource_name: + description: "Optional: scaffold a single NET-NEW resource with this name (a scaffoldable_resources entry inside a partial family). Blank = scaffold the whole family." + required: false + type: string + operations: + description: "Optional: comma-separated operationIds the net-new resource should cover. Used with resource_name." + required: false + type: string notes: description: "Optional extra instructions for the scaffolding agent" required: false @@ -34,7 +42,7 @@ permissions: jobs: scaffold: - name: Scaffold ${{ inputs.family }} + name: Scaffold ${{ inputs.resource_name || inputs.family }} runs-on: ubuntu-latest timeout-minutes: 60 steps: @@ -66,12 +74,11 @@ jobs: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} prompt: | - Scaffold a new resource for the LaunchDarkly Terraform provider covering - the "${{ inputs.family }}" API endpoint family. + ${{ inputs.resource_name && format('SCOPED MODE — implement exactly ONE net-new resource named `{0}` for the LaunchDarkly Terraform provider, covering ONLY these operationIds: {1}. It belongs to the partial "{2}" API family; do NOT modify, regenerate, or touch the family''s EXISTING resources, and do not model operations outside that list.', inputs.resource_name, inputs.operations, inputs.family) || format('Scaffold a new resource for the LaunchDarkly Terraform provider covering the "{0}" API endpoint family.', inputs.family) }} Inputs: - - Endpoint summary for the family: ./family-slice.json (repo root). - This is input context only — read it, but do NOT commit it. + - Endpoint summary for the family: ./family-slice.json (repo root). This + is the WHOLE family for context — read it, but do NOT commit it.${{ inputs.resource_name && format(' Model ONLY the operations listed above ({0}); ignore the rest of the family.', inputs.operations) || '' }} - Full OpenAPI spec (for schemas): https://app.launchdarkly.com/api/v2/openapi.json - Read .claude/skills/terraform-provider-add-resource/SKILL.md and its references/patterns.md (both vendored into this repo), and follow that @@ -86,15 +93,14 @@ jobs: Additional notes from the operator: ${{ inputs.notes }} Deliverable: - 1. Create a branch named scaffold/${{ inputs.family }} (lowercased, - non-alphanumerics replaced with '-') off preview-v3. - 2. Implement the resource (and data source if the family has GET-by-key), + 1. Create a branch named scaffold/${{ inputs.resource_name || inputs.family }} + (lowercased, non-alphanumerics replaced with '-') off preview-v3. + 2. Implement the resource (and data source if it has GET-by-key), unit-testable helpers, acceptance tests, docs templates, and provider - registration. Update scripts/driftreport/mapping.yaml to mark the family - covered. + registration. ${{ inputs.resource_name && format('In scripts/driftreport/mapping.yaml, move these operations out of the "{0}" family''s new_resource_candidates into a new entry under its resources (the family stays partial).', inputs.family) || 'Update scripts/driftreport/mapping.yaml to mark the family covered.' }} 3. Run go build ./... and make fmt; fix what they surface. 4. Commit, push the branch, and open a DRAFT pull request against - preview-v3 titled "feat: scaffold ${{ inputs.family }} resource + preview-v3 titled "feat: scaffold ${{ inputs.resource_name || inputs.family }} resource (autogen stage 2)". The PR body must state it is agent-scaffolded and needs human review per stage 3 of the autogen pipeline. Never push to preview-v3 directly and never merge anything. From b1015192f4d1489665364824b31890245453c145 Mon Sep 17 00:00:00 2001 From: Fabian <ffeldberg@launchdarkly.com> Date: Tue, 16 Jun 2026 11:13:09 +0100 Subject: [PATCH 4/6] make generate --- launchdarkly/integration_configs_generated.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/launchdarkly/integration_configs_generated.go b/launchdarkly/integration_configs_generated.go index 3227b008..f1276fc0 100644 --- a/launchdarkly/integration_configs_generated.go +++ b/launchdarkly/integration_configs_generated.go @@ -395,6 +395,14 @@ var SUBSCRIPTION_CONFIGURATION_FIELDS = map[string]IntegrationConfig{ IsSecret: false, Type: "string", }, + "url": { + AllowedValues: []string{}, + DefaultValue: "", + Description: "A link to the user's installation page on vercel.com", + IsOptional: true, + IsSecret: false, + Type: "string", + }, }, } From 883a602a56302249c98a9de27ffb45d7382c9200 Mon Sep 17 00:00:00 2001 From: Fabian <ffeldberg@launchdarkly.com> Date: Tue, 16 Jun 2026 11:16:53 +0100 Subject: [PATCH 5/6] fix: guard scoped scaffold inputs + account-scoped verify re-runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugbot findings on this PR: - scaffold-resource.yml: SCOPED MODE (resource_name set) needs the operationId list, but nothing required it. Add a fail-fast guard step that errors when resource_name is set with empty operations, instead of letting the agent enter scoped mode with no ops and the whole-family slice. - verify-scaffold.yml: the pre-clean step only deletes the tf-verify-pr<N> PROJECT, so an account-scoped resource reusing a stable name would 409 on a re-run — contradicting the "overwritten each run" comment. Account-scoped resources now get a per-run name (tf-verify-pr<N>-run<R>) so re-applies don't collide, and the header comment is corrected to state that project-scoped resources are pre-cleaned/overwritten while account-scoped ones accumulate and need manual cleanup. actionlint + YAML clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .github/workflows/scaffold-resource.yml | 8 ++++++++ .github/workflows/verify-scaffold.yml | 19 ++++++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.github/workflows/scaffold-resource.yml b/.github/workflows/scaffold-resource.yml index b9c1aeeb..63354dd4 100644 --- a/.github/workflows/scaffold-resource.yml +++ b/.github/workflows/scaffold-resource.yml @@ -51,6 +51,14 @@ jobs: run: | echo "::error::Scaffolding agent is disabled (repository variable SCAFFOLD_AGENT_ENABLED != 'true')." exit 1 + - name: Validate scoped inputs + # Scoped mode (resource_name set) needs the operationId list — otherwise + # the agent enters SCOPED MODE with no operations and the whole family + # slice, an ambiguous instruction. Fail fast instead. + if: inputs.resource_name != '' && inputs.operations == '' + run: | + echo "::error::'operations' is required when 'resource_name' is set (scoped mode needs the comma-separated operationId list)." + exit 1 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: preview-v3 # scaffolds always target the v3 line diff --git a/.github/workflows/verify-scaffold.yml b/.github/workflows/verify-scaffold.yml index c5548a75..2ffefa81 100644 --- a/.github/workflows/verify-scaffold.yml +++ b/.github/workflows/verify-scaffold.yml @@ -6,11 +6,14 @@ # `terraform plan`/`apply`, fixing functionality/tests until apply is clean. # # The applied example resources are RETAINED (no `terraform destroy`) so a human -# reviewer can inspect them in the LD account; they are namespaced by PR number -# (project key `tf-verify-pr<N>`) and overwritten on each run, so at most one -# set lingers per PR. Result posts back as a PR comment AND a private-Slack -# "ready for review" message (same generic title/body webhook as the drift -# report — SLACK_DRIFT_WEBHOOK_URL). +# reviewer can inspect them in the LD account. PROJECT-scoped resources live in a +# project keyed `tf-verify-pr<N>` that the pre-clean step deletes at the start of +# each run, so at most one set lingers per PR. ACCOUNT-scoped resources have no +# project to pre-clean, so the agent names them per-run (`tf-verify-pr<N>-run<R>`) +# to avoid colliding with a retained resource from a previous run; those +# accumulate and need manual cleanup. Result posts back as a PR comment AND a +# private-Slack "ready for review" message (same generic title/body webhook as +# the drift report — SLACK_DRIFT_WEBHOOK_URL). # # This file lives on `main` only so GitHub registers the workflow_dispatch # trigger. It checks out the PR's own branch (always a same-repo scaffold/* @@ -216,8 +219,10 @@ jobs: `tf-verify-pr${{ inputs.pr_number }}` (use this key so the live resources are identifiable and the workflow can pre-clean it next run), and the new resource (plus its data source if one was scaffolded). If the new resource is - account-scoped rather than project-scoped, namespace its key/name with - `tf-verify-pr${{ inputs.pr_number }}` instead. Then: + account-scoped rather than project-scoped, there is no project for the + workflow to pre-clean, so name its key/name per-run as + `tf-verify-pr${{ inputs.pr_number }}-run${{ github.run_number }}` to avoid + colliding with a resource retained by a previous run. Then: terraform -chdir="$RUNNER_TEMP/verify" plan -input=false terraform -chdir="$RUNNER_TEMP/verify" apply -input=false -auto-approve A second `plan` afterwards MUST be empty (no perpetual diff). Do NOT commit From 5c81a7914b1c6491e5b83e847503efb2ec3b8fd6 Mon Sep 17 00:00:00 2001 From: Fabian <ffeldberg@launchdarkly.com> Date: Tue, 16 Jun 2026 11:21:54 +0100 Subject: [PATCH 6/6] fix: sanitize gh PR fields before writing GITHUB_OUTPUT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugbot: the Resolve PR step wrote head_branch/pr_url/pr_title to GITHUB_OUTPUT via plain `echo key=value`. A PR title (free text; a JSON string from `gh ... --json title` can contain a newline) could inject a second `head_branch=…` line. Actions takes the last duplicate key, so checkout could use a ref that never passed the scaffold/* guard. Strip CR/newlines from headRefName, url, and title right after extraction (so the guard also sees the sanitized ref). head/url are single-line by construction; title is the real vector. Verified: a title containing `\nhead_branch=attacker-branch` is flattened to one line, leaving exactly one head_branch output (the real branch). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .github/workflows/verify-scaffold.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/verify-scaffold.yml b/.github/workflows/verify-scaffold.yml index 2ffefa81..374c55ca 100644 --- a/.github/workflows/verify-scaffold.yml +++ b/.github/workflows/verify-scaffold.yml @@ -79,10 +79,14 @@ jobs: fi meta="$(gh pr view "$PR" --repo "$REPO" --json headRefName,isCrossRepository,baseRefName,title,url,state)" cross="$(printf '%s' "$meta" | jq -r '.isCrossRepository')" - head="$(printf '%s' "$meta" | jq -r '.headRefName')" state="$(printf '%s' "$meta" | jq -r '.state')" - url="$(printf '%s' "$meta" | jq -r '.url')" - title="$(printf '%s' "$meta" | jq -r '.title')" + # Strip CR/newlines: these become step outputs via `key=value` lines, and + # a newline in the free-text PR title (a JSON string can contain one) could + # otherwise inject a second head_branch=… line that wins the duplicate-key + # race and bypasses the scaffold/* guard below. + head="$(printf '%s' "$meta" | jq -r '.headRefName' | tr -d '\r\n')" + url="$(printf '%s' "$meta" | jq -r '.url' | tr -d '\r\n')" + title="$(printf '%s' "$meta" | jq -r '.title' | tr -d '\r\n')" if [ "$cross" = "true" ]; then echo "::error::PR #$PR is from a fork; verification runs real applies with our credentials and only supports same-repo PRs." exit 1