Documentation

Get started fast. Go deep when you need to.

Expiry is an npm CLI for polyglot codebases. Add @expiry comments in TypeScript, Python, Go, Rust, Java, and more — CI checks whether the reason for the hack is gone and can open a cleanup PR. Watch the product video →

5-minute quick start

Try this in any Node.js repo — React, Next.js, Express, or a plain TypeScript monorepo.

  1. Try it in 30 seconds (no repo needed)
    npm install -g expiry && expiry demo

    Creates a sample expiry-demo/ folder with annotated files and runs an offline scan.

  2. Install the CLI
    npm install -g expiry
  3. Tag one piece of temporary code

    Put a comment on the line above the hack. The format is: condition, then a short note.

    // @expiry date:2026-12-31 | remove holiday banner
    function showHolidayBanner() {
      return true;
    }
  4. Scan your project
    expiry scan -p .

    Expiry lists every @expiry comment and whether it is pending, ready to remove, or overdue.

  5. Check your health score
    expiry stats --offline
  6. Add CI (optional)
    expiry init

    Creates .expiry.json and a GitHub Actions workflow. See CI setup below.

How it works

Traditional TODOs say when to delete code (“after Q3”). Expiry ties deletion to a condition your CI can verify — a closed ticket, an upgraded dependency, a removed env var.

Pending

The condition is not met yet. Code stays — nothing to do.

Ready

The condition is met. Time to delete the code (Pro can open a draft PR).

Overdue

A date passed or grace period expired. CI should block merges.

Annotation format

// @expiry <condition> | <human note> | owner:team (optional)
  • Condition — machine-readable signal (see conditions)
  • Note — what to remove and why (for humans in code review)
  • Pipe | — combine conditions: remove when any is satisfied

Tag temporary code

Single-line (most common)

One comment above the code you plan to delete:

// @expiry issue:ENG-442:closed | remove legacy auth prefix
export function legacyAuth(token: string) {
  return token.startsWith("legacy_");
}

Real-world examples

After a ticket closes
// @expiry issue:ENG-442:closed | drop v1 webhook handler
After a dependency upgrades
// @expiry dep:react@>=19.0.0 | remove React 18 shim
After a feature flag ships
// @expiry flag:NEW_UI:enabled | delete old layout branch
When an env var goes away
// @expiry env:LEGACY_CHECKOUT! | remove old checkout path
Multi-line blocks (advanced)

Wrap an entire module or function group when one condition covers multiple lines:

// @expiry-block issue:ENG-100:closed | remove legacy module
function legacyA() {}
function legacyB() {}
// @expiry-end

Works in //, #, and block comments. Expiry counts lines inside the block for health metrics.

Conditions

Pick the signal that means “this hack can go.” Combine with | — Expiry marks ready when any condition is satisfied.

Condition Example Ready when…
date:YYYY-MM-DD date:2026-06-01 Date passed (overdue after grace period)
issue:KEY:closed issue:ENG-123:closed GitHub issue closed
issue:KEY:closed:jira issue:PROJ-1:closed:jira Jira issue closed
dep:pkg@range dep:react@>=19.0.0 Installed version satisfies semver range
env:KEY! env:LEGACY_MODE! Environment variable is unset
env:KEY=val env:NODE_ENV=production Env var equals value
flag:NAME:enabled flag:NEW_UI:enabled Flag is true in .env
flag:NAME:removed flag:OLD_UI:removed Flag unset or disabled
file:path file:dist/legacy.js File exists (e.g. migration applied)
file-absent:path file-absent:legacy.sql File no longer exists
manual manual Human review only — never auto-ready

CLI commands

All commands accept -p <path> for the repo root (defaults to current directory).

Command What it does
expiry scan List all annotations and their status
expiry check CI gate — exits non-zero on ready/overdue (use in pipelines)
expiry stats Health score 0–100 with overdue/ready/pending counts
expiry validate Syntax-only — no network, catches malformed conditions
expiry report Markdown or SARIF output for security tooling
expiry init Create .expiry.json + GitHub workflow template
expiry watch -i 30 Poll every 30s and print changes (local dev)
expiry cleanup --dry-run Preview auto-removal (Pro license)
expiry pr --draft Open draft cleanup PR (Pro license)
expiry notify Send Slack/email summary (Pro license)
expiry scan -p . --offline
expiry check -p .
expiry report -p . --format sarif -o expiry.sarif

CI setup

Quick setup

  1. Run expiry init in your repo root
  2. Commit .expiry.json and .github/workflows/expiry.yml
  3. Add EXPIRY_LICENSE_KEY to GitHub Actions secrets (free tier works without a key for scan/check)
  4. Tag temporary code with @expiry comments and open a PR — the workflow runs automatically

GitHub Actions workflow

Generated by expiry init, or add manually:

name: Expiry
on:
  pull_request:
  push:
    branches: [main]

jobs:
  expiry:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm install -g expiry
      - run: expiry check -p .
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          EXPIRY_LICENSE_KEY: ${{ secrets.EXPIRY_LICENSE_KEY }}
Action inputs (reference)
InputDefaultDescription
fail-on-overduetrueFail CI when date conditions are past grace period
fail-on-readytrueFail when code is ready for removal
comment-on-prtruePost health score comment on pull requests
auto-cleanup-prfalseOpen draft removal PR (Pro license)
license-keyPro/Team license from dashboard

Outputs: health-score, ready, overdue, cleanup-pr-url

Configuration

.expiry.json in your repo root (created by expiry init):

{
  "gracePeriodDays": 7,
  "issueProvider": "auto",
  "github": { "owner": "your-org", "repo": "your-repo" },
  "jira": {
    "baseUrl": "https://your.atlassian.net",
    "email": "you@company.com"
  }
}

Environment variables

VariableRequired for
GITHUB_TOKENissue: checks in CI (usually provided by Actions)
EXPIRY_LICENSE_KEYPro features — auto-cleanup PRs, Slack, SARIF
JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKENJira issue conditions

Pro features

Free tier covers scan, check, validate, and CI gating. Pro and Team add automation after conditions are met:

  • Auto-cleanup PRs — draft PR with code removal when ready
  • Slack & email alerts — notify owners when debt is overdue
  • SARIF export — feed findings into GitHub Advanced Security
  • Jira sync — link annotations to tickets
export EXPIRY_LICENSE_KEY="your_key_from_dashboard"
expiry cleanup --dry-run -p .
expiry pr --draft -p .
expiry notify -p .

Compare Free, Pro, and Team → · Get your license key →

Troubleshooting

Scan finds nothing

Comments must include @expiry (case-insensitive) and a valid condition token. Run expiry validate -p . to see parse errors. Supported comment styles: //, #, --, /* */, ///, //!, and HTML <!-- -->. Default scan includes JavaScript, TypeScript, Python, Go, Rust, Ruby, Java, C, C++, C#, Kotlin, PHP, Swift, SQL, Vue, and Svelte.

Issue conditions stay pending in CI

Ensure GITHUB_TOKEN has permission to read issues, and the issue key matches (e.g. ENG-442). For Jira, set JIRA_* env vars and use issue:KEY:closed:jira.

expiry check fails but I want to merge

Either remove/update the annotation, fix the underlying condition, or use manual for items that need human sign-off. Overdue date conditions fail after gracePeriodDays (default 7).

Pro commands say license required

Set EXPIRY_LICENSE_KEY from your dashboard. Keys are tied to your Stripe subscription — monthly keys renew every ~35 days, annual keys every ~370 days. In CI, the GitHub Action validates your key online against /api/license/validate so cancelled subscriptions stop working immediately. Update the secret when your dashboard shows a renewed key.