Skip to main content
Back to Blog
Guide
2026-07-13

Quarantine Flaky Tests with a GitHub Actions Label

Quarantine flaky tests with a GitHub Actions pull-request label while keeping mandatory coverage, visible failures, expiry metadata, and auditable CI gates.

Quarantine Flaky Tests with a GitHub Actions Label

Build 814 fails on the same date-picker test for the third time, and rerunning turns it green. Removing the test protects CI speed but erases coverage; retrying the entire suite hides whether a new defect is intermittent. A controlled quarantine keeps the unstable test running in a separate, visible lane while the reliable suite remains a required gate.

A pull-request label can authorize that temporary routing. The label must not turn arbitrary red tests green. It should activate only a checked-in quarantine manifest whose entries name the test, owner, issue, reason, and expiry. Any failure outside that manifest stays blocking.

Define what the label is allowed to change

Use a deliberately specific label such as ci:quarantine-approved. Its meaning is not “ignore failures.” It means “run reviewed quarantine entries in a nonblocking job and run everything else as the required suite.” Repository permissions determine who can apply it, and branch protection determines which job must succeed.

Pipeline conditionMandatory suiteQuarantine laneMerge outcome
Label absentRun all testsNot neededAny failure blocks
Approved label present, manifest validExclude listed testsRun only listed testsReliable failure blocks; known flaky failure is visible
Label present, manifest expiredFail validationDo not grant exceptionMerge blocks
Label present, test not in manifest failsFailure remains in mandatory suiteIrrelevantMerge blocks
Quarantined test becomes stableMandatory suite still excludes until removalLane stays greenCleanup bot or owner removes entry
Manifest selector matches no testFail validation or collection checkEmpty lane is an errorMerge blocks

This design makes quarantine an explicit code and workflow change, not a label that can forgive an unknown result after the fact. The label controls routing only for already identified tests.

For general workflow structure and caching, see the GitHub Actions testing guide. The controls below are specific to flaky-test isolation.

Mark tests with stable quarantine tags

Playwright supports tags in test details and filtering with --grep and --grep-invert. Give each quarantined test a stable issue tag in addition to the common marker. The issue tag avoids relying on a title that maintainers may edit.

import { test, expect } from '@playwright/test';

test(
  'date picker crosses the daylight-saving boundary',
  { tag: ['@quarantine', '@flake-1842'] },
  async ({ page }) => {
    await page.goto('/booking');
    await page.getByLabel('Departure date').fill('2026-11-01');
    await page.getByRole('button', { name: 'Search' }).click();

    await expect(page.getByTestId('selected-date')).toHaveText('Nov 1, 2026');
  },
);

The test remains ordinary Playwright code. Do not use test.skip(), because skipped code supplies no pass or failure signal. Do not mark it test.fixme() unless the intention is to stop execution entirely. Quarantine is valuable precisely because the test continues producing evidence.

Other runners need an equivalent selection mechanism: pytest markers, Vitest project or filename patterns, JUnit tags, or a manifest consumed by a custom reporter. Use a selector the runner reports clearly.

Store ownership and expiry outside the workflow

A tag says which test is isolated; it does not explain why. Keep a machine-readable file under review:

{
  "entries": [
    {
      "tag": "@flake-1842",
      "owner": "checkout-quality",
      "issue": "https://github.com/example/shop/issues/1842",
      "reason": "Intermittent timezone conversion after WebKit navigation",
      "expiresOn": "2026-07-27"
    }
  ]
}

An issue URL is data, not an internal blog link, so it does not affect site navigation requirements. In a real repository, use its actual organization and issue.

Validate that every entry has a recognized tag, parseable future date, nonempty owner, and issue reference. Also scan tests so every @quarantine has an issue tag present in the manifest and every manifest tag selects at least one collected test. This bidirectional check prevents abandoned metadata and untracked quarantine.

Route jobs from the pull-request label

The workflow below runs on pull-request changes, including labeling. When ci:quarantine-approved is absent, the required job runs the complete suite. When present, it excludes @quarantine, while a separate job runs only quarantined tests and is allowed to report failure without failing the workflow.

name: Browser tests

on:
  pull_request:
    types: [opened, synchronize, reopened, labeled, unlabeled]

permissions:
  contents: read

jobs:
  validate-quarantine:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - run: pnpm install --frozen-lockfile
      - run: node scripts/validate-quarantine.mjs

  required-tests:
    needs: validate-quarantine
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps chromium
      - name: Run every test when quarantine is not approved
        if: ${{ !contains(github.event.pull_request.labels.*.name, 'ci:quarantine-approved') }}
        run: pnpm exec playwright test
      - name: Run the reliable gate when quarantine is approved
        if: ${{ contains(github.event.pull_request.labels.*.name, 'ci:quarantine-approved') }}
        run: pnpm exec playwright test --grep-invert @quarantine

  quarantined-tests:
    needs: validate-quarantine
    if: ${{ contains(github.event.pull_request.labels.*.name, 'ci:quarantine-approved') }}
    continue-on-error: true
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps chromium
      - run: pnpm exec playwright test --grep @quarantine
      - if: ${{ always() }}
        uses: actions/upload-artifact@v4
        with:
          name: quarantined-playwright-report
          path: playwright-report
          retention-days: 14

The expression uses GitHub's object filter to obtain label names and contains() to test membership. Including labeled and unlabeled in activity types causes a label change to create a new workflow run. synchronize handles new commits.

Pin actions to reviewed commit SHAs in higher-assurance repositories. Version tags keep the example readable but are mutable references.

Make the reliable job the branch-protection gate

Configure required-tests and validate-quarantine as required status checks. Do not require quarantined-tests, because its purpose is observation while remediation proceeds. Conversely, do not put continue-on-error on a step that runs both reliable and quarantined tests.

GitHub job-level continue-on-error: true lets the job conclude without failing the workflow, while the job UI still exposes its outcome. Artifact upload uses always() so traces and reports survive a red test command.

Branch protection keys checks by workflow/job identity. Rename jobs carefully, since required checks may need repository setting updates. Test the workflow on a nonprotected branch or a temporary repository rule before depending on it.

Validate expiry without timezone ambiguity

Expiry should be a date interpreted in UTC, and the validator should fail closed. This Node script reads the manifest, validates fields, and rejects entries whose expiry is today or earlier. It uses only built-in APIs.

// scripts/validate-quarantine.mjs
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';

const manifest = JSON.parse(
  await readFile(new URL('../test-quarantine.json', import.meta.url), 'utf8'),
);

assert.ok(Array.isArray(manifest.entries), 'entries must be an array');
const seen = new Set();
const today = new Date().toISOString().slice(0, 10);

for (const entry of manifest.entries) {
  assert.match(entry.tag, /^@flake-[0-9]+$/);
  assert.ok(!seen.has(entry.tag), `duplicate tag: ${entry.tag}`);
  seen.add(entry.tag);
  assert.ok(entry.owner.trim(), `missing owner for ${entry.tag}`);
  assert.match(entry.issue, /^https://github.com//);
  assert.match(entry.expiresOn, /^\d{4}-\d{2}-\d{2}$/);
  assert.ok(
    entry.expiresOn > today,
    `quarantine ${entry.tag} expired on ${entry.expiresOn}`,
  );
}

console.log(`Validated ${manifest.entries.length} quarantine entries`);

Lexical comparison is valid for zero-padded ISO calendar dates. A more complete validator should also confirm the date exists, because a string such as 2026-02-31 matches the regular expression. Construct a UTC date and round-trip its components, or use the repository's approved date library.

Expiry is a forcing function, not automatic deletion. When it arrives, the build blocks until an owner removes the quarantine after demonstrating stability or renews it with evidence and review.

Prevent labels from becoming an unrestricted bypass

Anyone who can label a pull request may be able to influence routing. Protect the workflow at several layers.

ControlFailure it prevents
Checked-in manifestLabel cannot quarantine an arbitrary new failure
CODEOWNERS on workflow and manifestException policy receives designated review
Required validation jobExpired or malformed entries cannot route
Exact runner tagOnly identified tests leave the blocking lane
Restricted label permissionsUntrusted contributor cannot self-approve
Pull-request event, read-only tokenFork code gets minimal repository authority
Artifact retentionFlaky evidence remains available for diagnosis

Avoid pull_request_target for running untrusted pull-request code. That event can provide privileged base-repository context, and checking out contributor code in it creates serious risk. The ordinary pull_request event with read-only permissions is suitable for test execution.

A label name is not authorization by itself. Repository role settings, CODEOWNERS review, environment protection, or a separate approval action must define who may apply the exception in your threat model.

Do not use retry as quarantine

Retries answer “did the same test pass on another attempt?” Quarantine answers “may this already identified unstable signal be nonblocking temporarily?” They are complementary but not interchangeable.

Global retries can turn a new race into a green build with no review. If retries are enabled, publish retry counts and treat a pass-on-retry as flaky telemetry. The quarantine decision should still require ownership and an issue.

MechanismKeeps test runningBlocks mergeIntended duration
Immediate rerunYesUsually based on final attemptDiagnostic moment
Runner retryYesDepends on final attemptStanding resilience, used carefully
Quarantine laneYesNo for listed testsShort, owned remediation window
Skip or disableNoNoLast resort, poorest evidence
Delete testNoNoOnly when behavior is obsolete

The AI flaky-test detection guide discusses classification signals. Regardless of detection method, routing must remain auditable and narrow.

Keep new failures in the mandatory lane

The central invariant is selector complementarity. With the label, required tests run --grep-invert @quarantine; the nonblocking job runs --grep @quarantine. A newly failing untagged test remains required.

Validate that no broad file pattern, project setting, or test annotation excludes additional cases. Compare Playwright's collected test list for full, reliable, and quarantine selections. The counts should satisfy full equals reliable plus quarantine, with no duplicate selection.

Beware dynamically generated titles and conditional test declaration. Collection must be stable in CI. A quarantined tag inside a branch that does not execute can make the manifest look valid locally and empty on the target runner.

When a pull request adds a quarantine tag, the manifest change and issue should be in the same review. Do not allow a label alone to mutate source files or create entries automatically without review.

Report quarantine as debt, not noise

Upload the HTML report, traces on failure, and a concise job summary listing tag, owner, issue, expiry, and current result. Avoid posting a fresh pull-request comment on every synchronization unless comments are updated in place; notification fatigue makes failures invisible.

Track age and result history. A quarantined test that passes consistently across a defined observation window is a removal candidate. A test that fails every run may be a deterministic defect, not a flake, and should be fixed or converted into a known product issue with an explicit test expectation.

No universal pass count proves stability. Choose a window based on execution frequency and risk, and report it as policy rather than a statistical certainty. High-impact payment or permission tests deserve a stricter path than cosmetic animation coverage.

Test the workflow policy itself

CI configuration deserves scenario tests before it becomes a merge gate. Open a draft pull request containing one reliably passing test, one deliberately failing untagged test, and one deliberately failing tagged test. Without the approval label, the complete required run must fail. With the label and valid manifest, the untagged failure must still fail the required job. After removing that untagged defect, the required job should pass while the quarantine lane displays its failure.

Then exercise metadata failures: expired date, missing owner, duplicate issue tag, manifest entry with no collected test, and tagged test with no manifest record. Each should block in validation. Remove and reapply the label to confirm the activity filters start fresh runs. These checks can live in a disposable workflow fixture repository if intentionally failing tests would disrupt the main project.

Review the Checks page as a maintainer would see it. A technically correct expression is insufficient if both jobs share confusing names or the nonblocking result looks green without opening details. Job summaries should say “quarantined test failed, merge exception active,” not “success.” The branch-protection list should contain stable job names that are present in both label branches. This human-facing verification catches policy gaps that YAML linting and expression syntax checks cannot.

Handle pushes, forks, and scheduled runs separately

The pull_request payload contains labels. A push event does not have github.event.pull_request, so copying the same expression into a multi-event workflow can produce surprising false conditions. Keep the label-routed workflow pull-request-specific or calculate an explicit event-aware flag.

On the default branch, run the reliable and quarantine lanes on a schedule without needing a PR label. This preserves trend data after merge. Scheduled quarantine failure can open or update an issue through a separate workflow with narrowly scoped permissions.

Fork pull requests may not receive repository secrets. The quarantine suite should not require privileged secrets merely because it is nonblocking. If a test needs a protected environment, route that concern through an approved, safe workflow rather than weakening secret controls.

A disciplined exit from quarantine

Fix the suspected race, run the test repeatedly in its representative matrix, and review historical evidence. Then remove both @quarantine and its manifest entry in one pull request. The label may remain as a repository capability for other approved entries, but it should be removed from the repaired PR if no quarantine applies.

After removal, the test returns to the mandatory selector automatically. Keep the issue link in commit history rather than permanent test comments unless the underlying behavior needs explanation.

Quarantine succeeds when it shortens diagnosis without normalizing red tests. If the manifest grows monotonically, the process is an avoidance mechanism. Set a team-level review cadence and capacity limit, but avoid a crude maximum that encourages deleting useful tests.

Frequently Asked Questions

Can a pull-request label safely make flaky failures nonblocking?

Yes, if it only routes tests already tagged in a reviewed, unexpired manifest. The mandatory job must exclude exactly those tests and remain a required check. A label that ignores arbitrary failures is unsafe.

Why not mark quarantined Playwright tests with test.skip?

Skipped tests produce no failure or recovery evidence. Running them in a nonblocking job preserves reports, traces, pass streaks, and evidence needed to remove the quarantine.

Should the quarantined job use continue-on-error at step or job level?

Use a dedicated job and apply continue-on-error there. Mixing reliable and quarantined commands in one forgiving step risks suppressing a new blocking failure and makes branch-protection status unclear.

How does the workflow react when a label is removed?

Include the unlabeled pull-request activity type. GitHub starts a new run, the label condition becomes false, and the mandatory job runs the full suite again.

What prevents an expired quarantine from continuing forever?

A required validation job compares each ISO expiry date with the current UTC date and fails closed. Renewal requires a reviewed manifest change, while removing the tag and entry restores the test to the reliable gate.

Quarantine Flaky Tests with a GitHub Actions Label | QASkills.sh