From 1b7a6a07e42c97446a6741e83c4265a2bf7294f2 Mon Sep 17 00:00:00 2001 From: Omer Cohen <639682+omercnet@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:17:29 +0000 Subject: [PATCH 1/2] chore: add Squawk teardown workflow Squawk is retired: tektum/verity-images now re-evaluates every published digest from its attested per-platform SPDX SBOMs inside GitHub Actions and reports fixable findings as code scanning alerts (verity-images#1086). The workflow destroys one Cloudflare environment per dispatch through the existing OpenTofu state, verifies through the Cloudflare API that no squawk worker, queue, or D1 database remains, and can retire the GitHub App installation using a short-lived App assertion. It requires a typed confirmation phrase and keeps no export of the destroyed database. --- .github/workflows/teardown.yml | 163 +++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 .github/workflows/teardown.yml diff --git a/.github/workflows/teardown.yml b/.github/workflows/teardown.yml new file mode 100644 index 0000000..670642f --- /dev/null +++ b/.github/workflows/teardown.yml @@ -0,0 +1,163 @@ +name: Teardown Squawk + +# Squawk is retired. tektum/verity-images now re-evaluates every published +# digest from its attested per-platform SPDX SBOMs inside GitHub Actions and +# reports findings as code scanning alerts (verity-images#1086), so this +# service owns no monitoring responsibility. This workflow deletes the +# Cloudflare footprint one environment at a time and retires the GitHub App +# installation. Nothing here is recoverable: the D1 database is destroyed +# without an export. +on: + workflow_dispatch: + inputs: + environment: + description: Cloudflare environment to destroy. + required: true + default: none + type: choice + options: [none, staging, production] + uninstall_app: + description: Also retire the Squawk GitHub App installation. + required: true + default: false + type: boolean + confirm: + description: Type "destroy squawk" to confirm. + required: true + type: string + +permissions: {} + +concurrency: + group: teardown-${{ inputs.environment }} + cancel-in-progress: false + +jobs: + infrastructure: + if: inputs.environment != 'none' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Confirm intent + env: + CONFIRM: ${{ inputs.confirm }} + run: | + set -euo pipefail + if [[ "$CONFIRM" != "destroy squawk" ]]; then + printf 'confirmation phrase did not match\n' >&2 + exit 1 + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: jetify-com/devbox-install-action@8c6a66ed6273138b1915457069de78cb52fe3bd7 # v0.15.0 + with: + devbox-version: "0.17.5" + enable-cache: "true" + + - run: devbox run install + + # The state records a worker version whose modules reference this bundle, + # so destroy plans against the same inputs the deploy created. + - name: Build Worker bundle + run: devbox run -- bun run wrangler deploy --dry-run --outdir .tmp/worker-build --env="" + + - name: Destroy Cloudflare resources + env: + AWS_ACCESS_KEY_ID: ${{ secrets.TF_STATE_ACCESS_KEY_ID }} + AWS_ENDPOINT_URL_S3: https://${{ vars.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com + AWS_SECRET_ACCESS_KEY: ${{ secrets.TF_STATE_SECRET_ACCESS_KEY }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + ENVIRONMENT: ${{ inputs.environment }} + TF_VAR_cloudflare_account_id: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + TF_VAR_environment: ${{ inputs.environment }} + TF_VAR_worker_bundle_path: ../.tmp/worker-build/index.js + run: | + set -euo pipefail + devbox run -- tofu -chdir=infra init -input=false \ + -backend-config="key=squawk/${ENVIRONMENT}.tfstate" + devbox run -- tofu -chdir=infra destroy -auto-approve + + - name: Verify the footprint is gone + env: + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + ENVIRONMENT: ${{ inputs.environment }} + run: | + set -euo pipefail + account="https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}" + worker="squawk-${ENVIRONMENT}" + read_resource() { + curl --fail --silent --show-error \ + --header "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + "${account}/$1" + } + workers=$(read_resource workers/scripts | + jq --arg worker "$worker" '[.result[]? | select(.id == $worker)] | length') + queues=$(read_resource queues | + jq --arg worker "$worker" '[.result[]? | select(.queue_name | startswith($worker))] | length') + databases=$(read_resource d1/database | + jq --arg worker "$worker" '[.result[]? | select(.name == $worker)] | length') + printf 'remaining workers=%s queues=%s databases=%s\n' "$workers" "$queues" "$databases" + if [[ "$workers" -ne 0 || "$queues" -ne 0 || "$databases" -ne 0 ]]; then + printf '%s still has Cloudflare resources\n' "$worker" >&2 + exit 1 + fi + + github-app: + if: inputs.uninstall_app + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: {} + steps: + - name: Confirm intent + env: + CONFIRM: ${{ inputs.confirm }} + run: | + set -euo pipefail + if [[ "$CONFIRM" != "destroy squawk" ]]; then + printf 'confirmation phrase did not match\n' >&2 + exit 1 + fi + + # Only the App itself may delete its own installation, so the request is + # authenticated with a short-lived App assertion rather than a token. + - name: Retire the installation + env: + APP_ID: ${{ secrets.SQUAWK_APP_ID }} + APP_KEY: ${{ secrets.SQUAWK_APP_PEM }} + INSTALLATION_ID: ${{ vars.GH_APP_INSTALLATION_ID }} + run: | + set -euo pipefail + cat > assertion.mjs <<'NODE' + import { createSign } from "node:crypto"; + + const encode = (value) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + const issued = Math.floor(Date.now() / 1000) - 30; + const unsigned = [ + encode({ alg: "RS256", typ: "JWT" }), + encode({ iat: issued, exp: issued + 300, iss: process.env.APP_ID }), + ].join("."); + const signer = createSign("RSA-SHA256"); + signer.update(unsigned); + const signature = signer.sign(process.env.APP_KEY, "base64url"); + process.stdout.write(`${unsigned}.${signature}`); + NODE + assertion=$(node assertion.mjs) + rm assertion.mjs + status=$(curl --silent --show-error --request DELETE \ + --header "Authorization: Bearer ${assertion}" \ + --header 'Accept: application/vnd.github+json' \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + --output response.json --write-out '%{http_code}' \ + "https://api.github.com/app/installations/${INSTALLATION_ID}") + printf 'delete installation %s returned %s\n' "$INSTALLATION_ID" "$status" + if [[ "$status" != 204 ]]; then + cat response.json >&2 + exit 1 + fi From 49ca85204bf8d88b2044bce1a31f575ceee4defd Mon Sep 17 00:00:00 2001 From: Omer Cohen <639682+omercnet@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:35:59 +0000 Subject: [PATCH 2/2] fix(teardown): make destroy runnable and verification complete Stage a placeholder Worker module instead of bundling the Worker: the generated matcher WASM and admin client are gitignored, so the dry-run bundle could not resolve them on a clean runner and every teardown would have stopped before destroy. Destroy reads the recorded worker version from state, not from disk. Page through the Cloudflare list endpoints so a resource on a later page cannot read as a successful teardown, and read the worker name from the environment inside the jq filters. Retire the GitHub App installation only with the final production teardown, since both environments share that installation. --- .github/workflows/teardown.yml | 55 +++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/.github/workflows/teardown.yml b/.github/workflows/teardown.yml index 670642f..7c1651c 100644 --- a/.github/workflows/teardown.yml +++ b/.github/workflows/teardown.yml @@ -61,10 +61,15 @@ jobs: - run: devbox run install - # The state records a worker version whose modules reference this bundle, - # so destroy plans against the same inputs the deploy created. - - name: Build Worker bundle - run: devbox run -- bun run wrangler deploy --dry-run --outdir .tmp/worker-build --env="" + # Destroy reads the recorded worker version from state, never from disk, + # so a placeholder module satisfies the declared variable without + # rebuilding the gitignored generated matcher and admin sources. + - name: Stage a placeholder Worker bundle + run: | + set -euo pipefail + mkdir -p .tmp/worker-build + printf 'export default { fetch: () => new Response(null, { status: 410 }) };\n' \ + > .tmp/worker-build/index.js - name: Destroy Cloudflare resources env: @@ -90,26 +95,42 @@ jobs: run: | set -euo pipefail account="https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}" - worker="squawk-${ENVIRONMENT}" - read_resource() { - curl --fail --silent --show-error \ - --header "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ - "${account}/$1" + # WORKER is exported so the jq filters can read it without embedding + # a shell-looking variable in a single-quoted program. + export WORKER="squawk-${ENVIRONMENT}" + # Cloudflare list endpoints paginate, so a resource on a later page + # must not read as a successful teardown. + count_matching() { + local path=$1 + local filter=$2 + local page=1 + local pages=1 + local matches=0 + local body + while [[ "$page" -le "$pages" ]]; do + body=$(curl --fail --silent --show-error \ + --header "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + "${account}/${path}?page=${page}&per_page=50") + matches=$((matches + $(jq \ + "[.result[]? | select(${filter})] | length" <<<"$body"))) + pages=$(jq -r '.result_info.total_pages // 1' <<<"$body") + page=$((page + 1)) + done + printf '%s' "$matches" } - workers=$(read_resource workers/scripts | - jq --arg worker "$worker" '[.result[]? | select(.id == $worker)] | length') - queues=$(read_resource queues | - jq --arg worker "$worker" '[.result[]? | select(.queue_name | startswith($worker))] | length') - databases=$(read_resource d1/database | - jq --arg worker "$worker" '[.result[]? | select(.name == $worker)] | length') + workers=$(count_matching workers/scripts '.id == env.WORKER') + queues=$(count_matching queues '(.queue_name | startswith(env.WORKER))') + databases=$(count_matching d1/database '.name == env.WORKER') printf 'remaining workers=%s queues=%s databases=%s\n' "$workers" "$queues" "$databases" if [[ "$workers" -ne 0 || "$queues" -ne 0 || "$databases" -ne 0 ]]; then - printf '%s still has Cloudflare resources\n' "$worker" >&2 + printf '%s still has Cloudflare resources\n' "$WORKER" >&2 exit 1 fi github-app: - if: inputs.uninstall_app + # The installation is shared by both environments, so it is retired only + # with the final production teardown. + if: inputs.uninstall_app && inputs.environment == 'production' runs-on: ubuntu-latest timeout-minutes: 10 permissions: {}