Skip to content

Flag Cleanup Action

Detecting a dead flag is the easy half. The Featureflip flag cleanup Action does the other half: it reads removal candidates from the public API, deletes the flag’s code path in your repository, and opens one pull request per flag for you to review. Merge it and the flag is archived, so the code and the dashboard stop disagreeing.

It runs inside your CI, in your repository, holding your token. No LLM is involved: the rewrites are AST transforms, so the same input always produces the same diff.

Add a workflow to the repository you want cleaned up. Both permissions below are required.

name: Featureflip flag cleanup
on:
schedule:
- cron: '0 9 * * 1' # Mondays, 09:00 UTC
workflow_dispatch: {}
permissions:
contents: write # push the removal branch
pull-requests: write # open the pull request
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: canopy-labs/featureflip-flag-cleanup-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
api-token: ${{ secrets.FEATUREFLIP_API_TOKEN }}
org: my-org
project: my-project
dry-run: true # first run: read the diffs before anything is opened

FEATUREFLIP_API_TOKEN is your own token, stored as a repository or organization secret. A read-only Viewer token scoped to the one project is enough for this workflow, and is what these instructions are verified against.

Start with dry-run: true. It computes and prints every diff without contacting GitHub at all. Every refusal a real run can produce is reachable in a dry run and reported identically, so the preview cannot approve something the next real run declines. The default is false, which opens real pull requests on the first run.

Archive the flag when the pull request merges

Section titled “Archive the flag when the pull request merges”

Merging the cleanup PR removes the code. The flag itself stays live until something archives it. mode: archive-on-merge closes that loop, as a second workflow on a different trigger:

name: Featureflip archive on merge
on:
pull_request:
types: [closed]
jobs:
archive:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: canopy-labs/featureflip-flag-cleanup-action@v1
with:
mode: archive-on-merge
api-token: ${{ secrets.FEATUREFLIP_ARCHIVE_TOKEN }}
org: my-org
project: my-project

Three things are worth knowing about it.

It needs a different token, and that is why it is a separate workflow. Archiving requires the Member role, while reading removal candidates needs only read access. Give this workflow its own secret. If a read token leaks it discloses flag names. A Member token can change flag state.

It needs no checkout and no permissions block. The run reads the pull request that triggered it and makes one API call. It touches no source and never calls the GitHub API.

It identifies the flag from the branch name, which the Action encodes reversibly when it opens the pull request. Nothing is read from the title or body, so editing those is safe, and so is squashing the merge.

The workflow above fires on every closed pull request. That is expected: anything this Action did not open is skipped with a one-line [not-a-removal-branch] and exits 0. A pull request you close without merging is likewise [not-merged] and leaves the flag alone, because declining a removal should never archive anything.

Archiving is idempotent, so a redelivered event or a re-run is harmless. It can still decline, and the message says which case you are in: a flag that another live flag lists as a prerequisite cannot be archived until that prerequisite is gone, and neither can one with a scheduled change still pointing at it.

When to reach for this, and when to reach for the MCP server

Section titled “When to reach for this, and when to reach for the MCP server”

Featureflip has two ways to get a finished flag out of your code, and they are built to cover different halves of the problem.

This Action is the unattended half. It runs on a schedule, uses no LLM, and produces the same diff for the same input every time. That makes it right for the mechanical bulk: a flag read at a literal key, in a shape the rewrite rules recognize, in any of thirteen languages. It opens a pull request and archives the flag when you merge.

The MCP server is the interactive half. Your agent calls find_stale_flags, you confirm which ones are genuinely finished, and the agent removes the conditional in your editor. The server supplies the staleness data and performs the flag mutation. It never edits files on its own.

The handoff between them is the declined-shape list above. A flag read behind your own wrapper, or keyed by a variable instead of a literal, is exactly what this Action refuses and exactly what an agent reading the surrounding code handles well. So a declined flag is not a dead end, it is a pointer to the other path.

Most teams end up running the Action weekly and reaching for the agent on whatever it names.

Removal runs across TypeScript, TSX, JavaScript, Java, Go, Python, Kotlin, C#, PHP, Ruby, ERB, Dart and Swift. That covers every language a Featureflip SDK is written for, plus the Rails view templates that read those flags: a flag branched on inside an .erb view is removed in the same pull request as the controller that used to set it up. Your Android, iOS and Flutter code is handled the same way your backend is, by the same run and the same pull request. Leave languages unset to run all of them, or name a subset to narrow a run.

The list of shapes it refuses is the specification, not a gap. Each one exists because folding it would change behavior:

  • A flag read behind your own wrapper. Most codebases wrap the SDK. Name the wrapper in accessors and it is matched exactly as an SDK call would be. Without that the wrapper is invisible and the flag reports no changes.
  • A key that is not a literal at the call site. A key built from a variable or a template cannot be matched by name.
  • A binding another part of the file could still reach, which a file-scoped check determines before any rewrite runs.
  • Any file whose rewrite does not survive a re-parse. Every changed file is parsed again and compared against its input. A file that fails is abandoned along with the whole flag, never committed and hoped over.

A declined flag is reported by name with the reason, and it turns the run red rather than passing quietly. Add it to ignore to silence it deliberately.

  • max-prs defaults to 10. A project with two hundred dead flags would otherwise get two hundred branches on the first morning. Nothing is lost: the next run continues where this one stopped, because flags already proposed are skipped.
  • staleness defaults to dead. Dead flags open ready-for-review pull requests, and stale opens drafts.
  • Exit codes. 0 means every candidate the run attempted succeeded, including “nothing to do”. 1 means at least one flag could not be proposed. 2 means the run could not start at all, and nothing was modified.
  • Run it from a checkout of your base branch. Removal branches are cut from the checkout, so a pull_request merge commit would drag its commits into every pull request.

The full input reference, the per-language read shapes it recognizes, and the safety gates in detail live in the Action’s own repository: canopy-labs/featureflip-flag-cleanup-action.

Related: stale flag detection explains how flags are classified Active, Stale or Dead in the first place, and what a bot must refuse covers why the declines above are the interesting part.