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.
A flag marked permanent is never a removal candidate. Kill switches, ops toggles and entitlement gates can serve one value for a year and still be doing their job, so the Action never opens a pull request to remove one.
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.
Installation
Section titled “Installation”Add a workflow to the repository you want cleaned up. Both permissions below are required.
name: Featureflip flag cleanupon: 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@v2 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 openedFEATUREFLIP_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.
The five modes
Section titled “The five modes”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.
mode | Trigger | What it does |
|---|---|---|
remove (the default) | schedule | Opens one removal pull request per dead flag |
archive-sweep | schedule | Archives the flags whose removal merged, or defers them until traffic drains |
archive-on-deploy | deployment_status | Archives the outstanding flags the deployed commit contains |
pr-command | issue_comment, types: [created] | Rebuilds a removal when you comment on its pull request |
archive-on-merge | pull_request, types: [closed] | Deprecated. Archives on the merge. See below |
Most repositories end up running two of those: the scheduled remove workflow above and
archive-sweep. Add archive-on-deploy if your pipeline creates GitHub Deployments.
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-sweep closes that loop, as a second workflow on a schedule. Each run finds
every flag in the project whose removal pull request merged but which is still live, and tries
to archive it:
name: Featureflip archive sweepon: 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@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: mode: archive-sweep api-token: ${{ secrets.FEATUREFLIP_ARCHIVE_TOKEN }} org: my-org project: my-projectThe loop closes on the deploy, not on the merge. Those are the same minute in some pipelines and days apart in others. 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] for that flag 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, and the next sweep archives the flag. Wherever your
merges and your deploys are separate events, a few [deferred] runs after each removal are
normal.
That refusal reads telemetry, though, so it can only rule on what it has seen. A flag nothing
has evaluated recently is let through. That is right for a flag that is truly dead, and wrong
for code that is still deployed but rarely reached, like a monthly job or an admin-only page.
If your deploys can trail your merges by more than a day and you have code like that, give the
sweep deployed-refs, or add
archive-on-deploy. Both check the deployed commit
itself.
A few more things are worth knowing about the sweep.
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 GITHUB_TOKEN with pull-requests: read. It runs on a schedule, so it has no
pull request handed to it. It asks GitHub once per live flag whether that flag’s removal branch
ever produced a merged pull request. It writes nothing to your repository and needs no checkout.
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.
Some refusals need a person, and those turn the run red. 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. Each is named on its own line with the reason, and the rest of the backlog still drains. The one exception is a prerequisite whose dependents are being removed too. That flag is deferred, and the sweep archives the dependents first.
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.
Only archive what is deployed
Section titled “Only archive what is deployed”If something in your pipeline already points a git ref at what is deployed, such as a tag your production deploy moves to the commit it shipped, hand the sweep that ref:
permissions: contents: read # is the removal's merge commit in each deployed ref? pull-requests: read # which merged removals exist?
# ... with: mode: archive-sweep api-token: ${{ secrets.FEATUREFLIP_ARCHIVE_TOKEN }} org: my-org project: my-project deployed-refs: | deployed/web deployed/apiA flag is then archived only once its removal is in every ref you list. A flag key doesn’t say
which of your builds reads it, so a removal shipped in the web bundle but not yet in the API is
still being read by the API. Until every ref contains the merge commit, the run reports the flag
as [pending-deploy] and exits 0, and the first sweep after the ref moves picks it up.
GitHub resolves each ref, so the sweep still needs no checkout, only contents: read. Each ref
must be a plain tag, branch or commit SHA. One GitHub cannot resolve, a typo or a deleted tag
say, fails the run for every outstanding flag and archives nothing. Featureflip’s own refusal
still applies on top, so a flag the refs contain can come back [deferred] while old traffic
drains.
Archive when the deploy actually lands
Section titled “Archive when the deploy actually lands”archive-sweep acts on the evidence of a merge, with Featureflip’s refusal standing between
that and an archive. 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 deployon: 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@v2 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: productionIt 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-sweep works on its own.
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.
archive-on-merge is deprecated
Section titled “archive-on-merge is deprecated”mode: archive-on-merge will be removed in the next major version of the Action. Until then
it behaves exactly as before, and newer releases print a warning on every run.
It archives on the merge, which is the wrong moment: the code is merged, not deployed. Because
Featureflip refuses to archive a flag it can still see being evaluated, nearly every run of it
ends in [deferred] and leaves the real work to archive-sweep. The only flags it still
archives on the spot are ones nothing has evaluated recently, and those include code that is
deployed but rarely reached, which it archives before any deploy has had a chance to land.
To migrate, delete the workflow that runs mode: archive-on-merge and run
archive-sweep on a schedule instead, plus
archive-on-deploy if your pipeline creates GitHub
Deployments. Keep the same Member-role secret. The sweep picks up every merged removal whose
flag is still live, including ones merged while the old workflow was running, so nothing is
lost in the switch.
Re-run a removal from a comment
Section titled “Re-run a removal from a comment”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 commandson: 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@v2 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 rerunrebuilds 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 recreatedoes 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 the archive modes read, 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.
Running it on CircleCI
Section titled “Running it on CircleCI”If your pipelines run on CircleCI, use the orb
featureflip/flag-cleanup
instead of the Action. It runs the same image, so everything this page says about languages,
refusals and pull requests holds there too. Your code still has to be hosted on
GitHub, since that’s where those pull requests get opened.
version: 2.1orbs: featureflip: featureflip/flag-cleanup@2workflows: flag-cleanup: jobs: - featureflip/cleanup: context: featureflip org: my-org project: my-project dry-run: trueKeep FEATUREFLIP_API_TOKEN in that context, alongside a GITHUB_TOKEN allowed to push
branches and open pull requests. Then give the workflow a scheduled trigger in the project
settings. The orb has two modes: remove, and archive-sweep with its own Member token in
api-token. You won’t find the other three here. They react to GitHub Actions events, and a
CircleCI pipeline never sees one.
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, or at a constant holding one, 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 value built at runtime, 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.
Supported languages
Section titled “Supported languages”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.
Methods that wrap one flag
Section titled “Methods that wrap one flag”Plenty of code reads a flag once, inside a method, and checks that method everywhere else:
public boolean isNewCheckoutEnabled(EvaluationContext ctx) { return client.boolVariation("new-checkout", ctx, false);}
// elsewhereif (flags.isNewCheckoutEnabled(ctx)) { newCheckout(); } else { oldCheckout(); }In each supported language, the Action folds the read inside the method,
then follows the method to its callers. Every call its file proves is bound to this method is
folded too, and once nothing calls the method, it is deleted. Here that leaves newCheckout()
and no isNewCheckoutEnabled at all. A Kotlin property getter or top-level function and a C#
get-only bool property are followed the same way, as long as the whole body is that one read.
In Go the wrapper is a function or method that returns one bool and whose body is only
return and the read. A call is folded when its file shows where it goes: a bare call inside
the same package, a call through an import of that package, or a method on a receiver whose type
the file spells out. Go interfaces are satisfied without being declared, so an exported method
is never deleted, since code outside the repository may rely on it. Its callers are still folded.
In TypeScript and JavaScript the wrapper is a top-level function, a const holding an arrow
function, a method of a class that takes no type parameters, a class field holding an arrow
function, or a member of an object literal that a top-level const holds. Class types there are
structural, so the name has to be declared exactly once across the repository’s TypeScript and
JavaScript. An async function, a getter or an export default keeps the old behavior: the read
inside is folded and the wrapper stays.
Versions 1.11.0 to 1.13.0 brought the same to Python, Dart, PHP, Ruby and Swift. In Python the wrapper is a
function, method or @property, and a call counts when it is bare in its own module, goes through
a top-level import of that module, or goes through self or cls. A wrapper a test patches or
mocks, or one listed in __all__, stays, though its callers are still folded. In Dart, PHP and
Swift it is a function, method or getter declared to return bool (or Bool). In Ruby it is an
instance method, a def self. method or a module function. Ruby can call a method by name, so a
symbol or string spelling the wrapper anywhere keeps it, and a call from an .erb view keeps it
too. In Dart, PHP, Ruby and Swift, a public wrapper of a package that may be published is kept. The
Action’s README, under “Wrapping the SDK”, lists each language’s exceptions.
Inlining can strand a constructor parameter. C# reports an unread one as CS9113, and TypeScript
under noUnusedLocals reports it as TS6138, so a strict build would fail on the pull request. A
C# primary-constructor parameter or a TypeScript constructor parameter property left that way is
deleted, along with the argument each caller passes for it, once every caller is accounted for.
If one isn’t, the parameter stays and the pull request names it. A TypeScript private or #
field left unread is deleted when its initializer can do no work, and named in the pull request
otherwise.
A C# wrapper with an interface leaves more behind once its last caller is folded: the interface
and its classes, now empty, their dependency-injection registrations, and a constructor parameter
in every class that asked for the interface. The Action deletes those too, along with the files
that held nothing else, the field each parameter was stored in, the argument every caller passed,
test mocks and tests that only built them, and the options class and appsettings*.json key an
implementation read. If any mention of those types can’t be accounted for, none of it is touched,
the pull request says what kept it, and the flag is still removed.
A call the Action can’t tie to the method stays, and the pull request lists each remaining caller
with the reason. A method a framework may call through an annotation or a decorator, like
@GetMapping, is kept even with no caller left. If your repository is a published library, check any public method the
diff deletes before merging, since code outside the repository may still call it. Set
inline-wrappers: false to fold only inside the method, as versions before 1.4.0 did.
What it declines to touch
Section titled “What it declines to touch”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 a wrapper that takes
the key in
accessorsand it is matched exactly as an SDK call would be. Without that the wrapper is invisible and the flag reports no changes. A method that takes no key works differently, see methods that wrap one flag. - A key built at runtime. A key assembled from a variable, a template or concatenation cannot be matched by name. A key held in a constant is followed, and the constant is removed along with its last read. In C#, Dart, Java, Kotlin, PHP, Python, Swift, TypeScript and JavaScript that includes a constant declared in another file.
- 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.
Before your first real run
Section titled “Before your first real run”max-prsdefaults 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.stalenessdefaults todead. Dead flags open ready-for-review pull requests, andstaleopens drafts.flagslimits a run to the keys you list. Paired withdry-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 (commentrerunon that one instead), and a key in bothflagsandignorestays skipped.- Exit codes.
0means every candidate the run attempted succeeded, including “nothing to do”.1means at least one flag could not be proposed.2means 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_requestmerge commit would drag its commits into every pull request.
Reference
Section titled “Reference”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.