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, ship 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.

mode decides what one invocation does. Each mode is a separate workflow on its own trigger, never a step inside a shared job, because the triggers differ and so do the tokens.

modeTriggerWhat it does
remove (the default)scheduleOpens one removal pull request per dead flag
archive-on-mergepull_request, types: [closed]Archives the flag whose removal just merged, or defers it
archive-sweepscheduleRetries the archives that deferred
archive-on-deploydeployment_statusArchives the outstanding flags the deployed commit contains
pr-commandissue_comment, types: [created]Rebuilds a removal when you comment on its pull request

Most repositories end up running three of those: the scheduled remove workflow above, archive-on-merge, and archive-sweep. Those last two work as a pair, for a reason the next two sections spend their time on.

Archive the flag once the removal is deployed

Section titled “Archive the flag once the removal is deployed”

Merging the cleanup PR removes the code. The flag itself stays live until something archives it. mode: archive-on-merge starts closing 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

The loop closes on the deploy, not on the merge. Those are the same minute in some pipelines and days apart in others, and which one you have decides how this mode behaves for you. Archiving is not a soft state. The flag leaves the evaluation payload within seconds, every connected SDK drops it, and each read falls back to the default value written beside it at the call site. If a build that still reads the flag is running in production at that moment, that fallback is what production serves from then on.

Which way the feature moves is not a coin flip. This Action only proposes flags whose telemetry says they have served one variation and nothing else for months. For a flag that is fully rolled out, that variation is the on one, while the default beside the read is the value its author wrote for the case where the flag cannot be reached at all, which is almost always the off one. So archiving a rolled-out flag ahead of its deploy does not disturb the feature at random. It turns the feature off, for everyone, until the deploy lands.

Featureflip refuses the archive while it can still see the flag being evaluated, which is what stops that being a silent outcome. The run reports [deferred] and exits 0. Nothing failed and nothing is yours to fix, because the refusal lifts by itself once the deploy lands and the old traffic drains.

So wire up archive-sweep as well. A deferred archive that nothing retries is a leak: the flag stays live forever and every run keeps exiting 0, which is exactly the half-closed loop this mode exists to end. Wherever your merges and your deploys are separate events, [deferred] is the ordinary result of a removal merge and the sweep in the next section is what actually archives the flag.

Three more 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 also fail for a reason no retry clears, and those turn the run red instead of deferring. 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. Both need a person.

mode: archive-sweep is what comes back for the flags above. On a schedule, it finds every flag in the project whose removal pull request merged but which is still live, and tries to archive each one again:

name: Featureflip archive sweep
on:
schedule:
- cron: '0 6 * * *'
workflow_dispatch: {}
permissions:
pull-requests: read # did this flag's removal branch ever merge?
jobs:
sweep:
runs-on: ubuntu-latest
steps:
- uses: canopy-labs/featureflip-flag-cleanup-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
mode: archive-sweep
api-token: ${{ secrets.FEATUREFLIP_ARCHIVE_TOKEN }}
org: my-org
project: my-project

Run this, not just archive-on-merge. The two are one mechanism split across two triggers, and the merge half is the half that usually cannot finish. Skipping the sweep is how a repository ends up with a tidy source tree and a dashboard full of flags nobody removed.

It needs GITHUB_TOKEN, and archive-on-merge does not. That is the one real cost of the mode. Not firing on the merge event means it has no pull request handed to it, so it asks GitHub once per live flag whether that flag’s removal branch ever produced a merged pull request. Read access to pull requests is all it wants, and it writes nothing to your repository.

Evidence of a merge is what authorizes each archive. A removal pull request you closed without merging is a removal you declined, and it reads to this mode exactly like a flag that was never proposed. Nothing is archived that this Action did not open a pull request for.

It needs the same Member-role token, for the same reason, and it keeps the same quiet and loud split. archived and deferred are both fine and exit 0. A flag the API refuses for any other reason is named on its own line and exits 1 without stopping the rest of the backlog draining.

There is no state to manage. Nothing is written when an archive defers, because a flag still being live is the record, so running this more often, less often or twice at once is harmless and there is nothing to reset if you turn it off. dry-run: true prints the flags it would attempt and sends nothing. A run with nothing to do prints a line saying so.

One case is genuinely not covered, and no amount of retrying fixes it: you ship to clients you cannot update. Mobile apps, desktop apps and embedded devices keep evaluating the flag for as long as people keep running the old version, which can be months, so the refusal never clears on its own and the sweep defers indefinitely. That is the correct behavior, since the flag really is still being read, but it means archiving those is a decision rather than a schedule. Archive them by hand once the old versions are gone.

archive-on-merge fires on the merge, which is the wrong event: it proposes an archive against a commit nobody is running yet. If your pipeline creates GitHub Deployments, mode: archive-on-deploy fires on the deploy instead, and archives only the outstanding flags whose removal that deployment actually contains:

name: Featureflip archive on deploy
on: deployment_status
permissions:
contents: read # is the removal's merge commit in the deployed one?
pull-requests: read # which merged removals exist?
jobs:
archive:
runs-on: ubuntu-latest
steps:
- uses: canopy-labs/featureflip-flag-cleanup-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
mode: archive-on-deploy
api-token: ${{ secrets.FEATUREFLIP_ARCHIVE_TOKEN }}
org: my-org
project: my-project
deployment-environment: production

It does not replace the sweep, and it does not avoid the deferral. Featureflip refuses an archive while it can still see the flag being evaluated, and a deployment finishes seconds after the build it replaced was serving that flag, so for any flag with live traffic this mode’s ordinary outcome is [deferred] too. Run archive-sweep on a schedule alongside it. Without one, a repository that deploys weekly defers every removal and never drains.

What this mode adds is the half the refusal cannot do. The refusal reads traffic your environments have already reported, so it can only rule on environments it has seen traffic from, and it lets through a flag nothing has evaluated. That is right when the flag really is dead and wrong when the code reading it is deployed but rarely reached. This mode asks git instead, and git cannot be vague about a commit: either your deployment contains the removal or it does not. The two checks fail in opposite directions, which is the whole reason to run both.

Then read the two limits, because they decide whether this mode is worth wiring up at all.

It only helps if something creates GitHub Deployments. Plenty of pipelines do, including Vercel, Netlify, actions/deploy-pages, and any workflow that names a GitHub environment:. Plenty do not, and there is no event for this mode to hang on in those. Nothing is lost by skipping it: archive-on-merge plus archive-sweep behave exactly as they did.

It does nothing for clients you cannot update. Mobile, desktop and embedded builds keep evaluating the flag long after your servers stop, and no git event corresponds to the last old version stopping. Deploying is simply not the completion event there, which is why those flags stay covered by Featureflip’s own refusal and the [deferred] outcome described above.

It asks whether the deployed commit contains the removal, and never assumes. A flag whose removal is not in that commit yet is reported as [pending-deploy] and left alone for the deploy that ships it, and nothing is written to record that, so nothing has to be cleaned up. A flag GitHub will not answer for at all is named and turns the run red. I could not tell is a different answer from not yet, and it will not clear by itself. The rest of the backlog is archived either way.

A deployment to another environment archives nothing, and says so out loud, because a workflow that quietly never fires is indistinguishable from a repository with nothing to clean up. deployment-environment is what names the one you archive on, and it is matched case-insensitively. Any status that is not success is likewise reported and archives nothing, since a deployment that failed left the old build running.

The scheduled sweep proposes each flag once. After a pull request exists for it, open or closed, the sweep leaves that flag alone, which is what stops a removal you declined from coming back every Monday. A removal can still go stale in place. The base branch moves on, or you fix an accessors entry and want the diff it should have produced in the first place. mode: pr-command rebuilds it when you ask on the pull request. It runs as a third workflow, on comments:

name: Featureflip cleanup commands
on:
issue_comment:
types: [created]
permissions:
contents: write # force-push the rebuilt branch
pull-requests: write # update the pull request and reply
jobs:
pr-command:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # your default branch, where the rebuild starts
- uses: canopy-labs/featureflip-flag-cleanup-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
mode: pr-command
api-token: ${{ secrets.FEATUREFLIP_API_TOKEN }}
org: my-org
project: my-project
# If the scheduled workflow sets directories, languages,
# accessors or ignore, copy the same values here.

Then comment on the removal pull request, with the command on a line of its own:

  • @featureflip-cleanup rerun rebuilds the removal from the pull request’s own base branch and force-pushes the result over its branch. It refuses if anyone other than the Action has committed there, so a fix-up you pushed by hand never disappears without you asking.
  • @featureflip-cleanup recreate does the same rebuild and pushes over those commits, discarding them.

Neither command takes a flag key. The flag comes from the pull request’s branch name, the same encoding archive-on-merge reads, so a comment can’t point a re-run at a different flag. The push also carries a lease on the branch’s current commit. Push to the branch while a re-run is working and the Action’s push is rejected, leaving yours intact.

Only people with write access can run a command. Anyone else’s comment gets no reply. issue_comment fires for everyone who can comment, and answering all of them would hand a megaphone to whoever types the mention.

Copy the transform inputs from the scheduled workflow. directories, languages, accessors and ignore feed the same rewrite the sweep runs, so a mismatch rebuilds a different diff under the same title. org, project and api-url have to name the same project too. The sweep’s read-only token is enough, because this mode never changes a flag. The other inputs do nothing here. staleness can’t change the diff, since the command looks the flag up in both tiers. flags, max-prs and pr-labels only shape a sweep that opens new pull requests, and the rebuild always starts from the pull request’s own base, whatever base-branch says.

A key in ignore is refused. Ignoring a flag is how you silence it, and a comment shouldn’t bring it back.

dry-run: true previews without pushing. The rebuild runs in full and the reply lists what the push would have done, down to how many commits it would have replaced. This dry run does talk to GitHub, unlike the sweep’s. It checks the commenter’s access and reads the pull request before replying, and skips only the push and the pull request update.

A closed pull request is reopened by the rebuild. A merged one is refused outright, since its code is already on the base branch and there is nothing left to remove.

In this mode an exit code of 0 means the comment was answered. That covers comments the Action ignores, every successful rebuild, a dry run, and an empty rebuild, which tells you nothing in the configured directories reads the flag any more and the pull request can be closed. 1 is a refusal or a failed rebuild. Commits from someone else without recreate, a merged pull request, a flag that stopped being a removal candidate and a key in ignore all land here, and each gets a reply saying which. 2 means the run never started, and nothing was pushed. The usual causes are a workflow listening to something other than issue_comment, or a checkout with uncommitted changes to tracked files.

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 once the removal is deployed.

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 below. 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.
  • flags limits a run to the keys you list. Paired with dry-run: true, it prints one flag’s diff without contacting GitHub at all, a quick way to judge a single removal before letting a run near the rest. It never re-proposes a flag that already has a pull request (comment rerun on that one instead), and a key in both flags and ignore stays skipped.
  • 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.