diff --git a/.github/workflows/apply.yaml b/.github/workflows/apply.yaml index 3f883a6..58a83d9 100644 --- a/.github/workflows/apply.yaml +++ b/.github/workflows/apply.yaml @@ -11,6 +11,10 @@ on: description: "One-time: import ID for the resource above (e.g. the repo name for github_repository)." required: false default: "" + state_rm_addresses: + description: "One-time: comma-separated Terraform resource addresses to remove from state (e.g. for a member who left the org and whose resource can no longer be refreshed). Leave empty for a normal apply." + required: false + default: "" schedule: - cron: "17 */4 * * *" push: @@ -21,8 +25,24 @@ on: - main concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + # This workflow has no pull_request trigger, so github.event.pull_request.number + # is always null here -- the group key collapsed to just github.ref in + # practice, which only serializes runs against the same ref. A manual + # workflow_dispatch run (e.g. one-time state rm) dispatched against a + # non-default ref could then run concurrently with a scheduled/push apply + # against main, racing on the same remote state. Use a single, unqualified + # group so every run of this workflow -- manual or automatic, any ref -- + # is always serialized against every other. + group: ${{ github.workflow }} cancel-in-progress: false + # Default queue behavior only keeps the single most-recently-queued run + # pending in a group -- an older pending run gets canceled and replaced. + # A manual one-time recovery run (state rm, or the exclude_addresses + # escape hatch) dispatched while a scheduled/push run is in progress + # could get silently dropped and replaced by the next automatic trigger + # before it ever executes. queue: max keeps every pending run queued + # (up to GitHub's cap of 100) instead of dropping older ones. + queue: max jobs: apply: @@ -64,6 +84,31 @@ jobs: tofu import "${{ inputs.import_address }}" "${{ inputs.import_id }}" env: GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + # Handles a resource whose live state can no longer be refreshed (e.g. + # a github_membership/github_team_membership for a user who left the + # org, which errors on read instead of just reporting "gone") -- + # refresh failures like this abort the whole apply before anything + # else in the plan can land, atomically, even resources unrelated to + # the broken one. Manual, one-time use via workflow_dispatch input; a + # no-op (skipped entirely) for the normal scheduled/push triggers, + # which never set this input. + - name: TF State Remove (one-time, manual only) + if: inputs.state_rm_addresses != '' + run: | + if [[ "${STATE_RM_ADDRESSES}" == *$'\n'* ]]; then + echo "::error::state_rm_addresses must be comma-separated on a single line, not newline-separated." >&2 + exit 1 + fi + IFS=',' read -ra ADDRS <<< "${STATE_RM_ADDRESSES}" + for addr in "${ADDRS[@]}"; do + if [[ -z "${addr}" || "${addr}" == -* ]]; then + echo "::error::invalid resource address '${addr}' -- addresses must be non-empty and cannot start with '-' (tofu would parse it as an option)." >&2 + exit 1 + fi + done + tofu state rm "${ADDRS[@]}" + env: + STATE_RM_ADDRESSES: ${{ inputs.state_rm_addresses }} - name: TF Apply run: | tofu apply -concise -auto-approve