Skip to content

[concurrency] Concurrency Safety: Cross-repo cache pollution in assign_milestone #38337

@github-actions

Description

@github-actions

Concurrency Safety Issue in assign_milestone

Severity: HIGH
Tool: assign_milestone
File: actions/setup/js/assign_milestone.cjs
Analysis Date: 2026-06-10

Summary

The assign_milestone tool maintains a closure-level cache (milestoneByTitle) and an exhaustion flag (milestonesExhausted) that are keyed only by milestone title — with no repo context. In multi-repo configurations (when allowed_repos lists more than one target repository), sequential processing of messages for different repos corrupts this shared state, causing wrong milestone assignments or silent lookup failures.

Issue Details

Type: Shared Mutable State — incorrect cache key scope
Location: actions/setup/js/assign_milestone.cjs lines 72–110, 205

Problematic Code:

// Line 72 — cache keyed by title only, no repo scope
const milestoneByTitle = new Map();

// Line 75 — single exhaustion flag for ALL repos
let milestonesExhausted = false;

async function findMilestoneByTitle(title, owner, repo) {
  if (milestoneByTitle.has(title)) {
    return milestoneByTitle.get(title);  // ❌ Returns data from ANY previously-queried repo!
  }
  if (milestonesExhausted) {
    return null;  // ❌ Short-circuits even for repos never yet searched!
  }
  // ... paginate using (owner, repo) correctly ...
  milestoneByTitle.set(m.title, m);  // ❌ Cached without repo context
  if (!found) {
    milestonesExhausted = true;  // ❌ Marks ALL future repos as exhausted
  }
}

Bug Scenarios

Scenario A — Wrong milestone number assigned (Cache Collision):

  1. Handler processes message for org/repo1: finds milestone "v1.0" with number 5 → caches it
  2. Handler processes message for org/repo2: looks for milestone "v1.0" (number 3 in repo2)
  3. Cache hit! Returns milestone #5 (from repo1)
  4. issues.update call assigns milestone Add workflow: githubnext/agentics/weekly-research #5 to a repo2 issue — wrong milestone, silent failure

Scenario B — Milestone not found when it exists (False Exhaustion):

  1. Handler processes message for org/repo1: milestone "sprint-42" not found → milestonesExhausted = true
  2. Handler processes message for org/repo2: looks for milestone "sprint-42" (exists in repo2 as Weekly Research Report: AI Workflow Automation Landscape and Market Opportunities - August 2025 #7)
  3. milestonesExhausted is already true → returns null immediately without paginating repo2
  4. Milestone assignment fails silently even though the milestone exists in repo2

Scenario C — Misleading error messages:

  • allFetchedMilestones accumulates milestones from all repos. When a milestone is not found, the error message lists milestones from unrelated repos, making debugging confusing.
Detailed Analysis

Root Cause

findMilestoneByTitle receives owner and repo parameters and correctly uses them for the GitHub API pagination call. However, the closure-level cache (milestoneByTitle) and exhaustion flag (milestonesExhausted) are not scoped to a specific repo:

  • milestoneByTitle maps title → milestone object instead of "owner/repo::title" → milestone object
  • milestonesExhausted is a single boolean instead of a Set<string> of exhausted "owner/repo" identifiers

The optimization was designed for single-repo use but is used in a multi-repo handler that can process messages across different repos in one run.

When Is This Triggered?

The bug requires:

  1. assign_milestone handler configured with multiple repos (allowed_repos with 2+ entries, or messages with different repo fields pointing to different repos)
  2. Multiple messages processed targeting different repos
  3. Either same milestone title in multiple repos (Scenario A) or a not-found search for one repo (Scenario B)

Single-repo configurations are not affected.

Recommended Fix

Approach: Scope all cache structures by owner/repo

// ✅ SAFE: Composite key includes repo identity
const milestoneByTitle = new Map();  // key: "owner/repo::title"
const exhaustedRepos = new Set();    // entries: "owner/repo"
const fetchedMilestonesByRepo = new Map(); // key: "owner/repo" → Array

async function findMilestoneByTitle(title, owner, repo) {
  const repoKey = `${owner}/${repo}`;
  const cacheKey = `${repoKey}::${title}`;

  if (milestoneByTitle.has(cacheKey)) {
    return milestoneByTitle.get(cacheKey);
  }
  if (exhaustedRepos.has(repoKey)) {
    return null;
  }

  const repoMilestones = fetchedMilestonesByRepo.get(repoKey) || [];
  let found = false;
  await githubClient.paginate(
    githubClient.rest.issues.listMilestones,
    { owner, repo, state: "all", per_page: 100 },
    (response, done) => {
      for (const m of response.data) {
        const key = `${repoKey}::${m.title}`;
        if (!milestoneByTitle.has(key)) {
          milestoneByTitle.set(key, m);
          repoMilestones.push(m);
        }
        if (m.title === title) {
          found = true;
          done();
          return;
        }
      }
    }
  );
  fetchedMilestonesByRepo.set(repoKey, repoMilestones);

  if (!found) {
    exhaustedRepos.add(repoKey);
  }
  return milestoneByTitle.get(cacheKey) || null;
}

Also update the auto-create cache write and the error message to use the repo-scoped structures.

Implementation Steps:

  1. Replace let milestonesExhausted = false with const exhaustedRepos = new Set()
  2. Replace const milestoneByTitle = new Map() key strategy: use "owner/repo::title" composite keys
  3. Replace let allFetchedMilestones = [] with const fetchedMilestonesByRepo = new Map()
  4. Update milestoneByTitle.set(created.data.title, created.data) (auto-create path, line 205) to use composite key
  5. Update error message at line 209 to use fetchedMilestonesByRepo.get(repoKey) || []
  6. Add regression tests for multi-repo milestone title collision and false-exhaustion scenarios
Alternative Solutions

Option 1: Reset cache on repo change

  • Track lastQueriedRepo; reset cache when repo changes between messages
  • Pros: Minimal code change
  • Cons: Loses caching benefit when messages interleave repos

Option 2: Remove the cache entirely

  • Always paginate fresh on each call
  • Pros: Eliminates all cache-related bugs
  • Cons: N API calls instead of 1 per distinct title (performance regression for single-repo)

Option 3: Composite key (recommended)

  • Correct, performant, minimal change

Testing Strategy

describe("cross-repo milestone cache isolation", () => {
  it("should not return cached milestone from repo1 when querying repo2", async () => {
    const { main } = require("./assign_milestone.cjs");
    const handler = await main({ allowed_repos: ["org/repo1", "org/repo2"] });

    // repo1: v1.0 = #5; repo2: v1.0 = #3
    mockGithub.paginate.mockImplementation(async (fn, params, cb) => {
      const data = params.repo === "repo1"
        ? [{ title: "v1.0", number: 5 }]
        : [{ title: "v1.0", number: 3 }];
      cb({ data }, () => {});
    });

    await handler({ type: "assign_milestone", issue_number: 10, milestone_title: "v1.0", repo: "org/repo1" }, {});
    await handler({ type: "assign_milestone", issue_number: 20, milestone_title: "v1.0", repo: "org/repo2" }, {});

    expect(mockGithub.rest.issues.update).toHaveBeenLastCalledWith(
      expect.objectContaining({ repo: "repo2", milestone: 3 })  // must be 3, not 5
    );
  });

  it("should not skip repo2 search after repo1 exhaustion", async () => {
    // repo1: no milestones; repo2: has sprint-42 = #7
    // After repo1 exhaustion, repo2 search must still succeed
  });
});

References:

  • Workflow Run: §27270264916
  • Related: Similar cross-repo cache bug reported in create_issue (createdTitlesByRepo, parentIssueCache)
  • File: actions/setup/js/assign_milestone.cjs

Priority: P1-High
Effort: Small (< 1 day)
Expected Impact: Prevents silent wrong-milestone assignments and missed lookups in multi-repo assign_milestone configurations

Generated by 📊 Daily MCP Tool Concurrency Analysis · ⌖ 38.3 AIC · ⊞ 20.2K ·

  • expires on Jun 17, 2026, 3:23 AM UTC-08:00

Metadata

Metadata

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions