Skip to content

safe-outputs: validate extracted base branch with git check-ref-format refs/heads/<name>#40001

Merged
pelikhan merged 3 commits into
mainfrom
copilot/fix-extract-base-branch-regex
Jun 18, 2026
Merged

safe-outputs: validate extracted base branch with git check-ref-format refs/heads/<name>#40001
pelikhan merged 3 commits into
mainfrom
copilot/fix-extract-base-branch-regex

Conversation

Copilot AI commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

The auto-injected extract-base-branch step used a regex that diverged from Git ref rules, accepting invalid branch names and rejecting valid ones (for example release/v1.2+hotfix). This change aligns extraction-time validation with Git's canonical branch validation semantics.

  • Validation semantics

    • Replaced regex-based base-branch validation in extract_base_branch_from_agent_output.cjs with git check-ref-format refs/heads/<name>.
    • Uses the refs/heads/ form (not --branch) to validate as a literal ref, preventing @{-N} git branch expressions (e.g. @{-1}) from passing validation.
    • Added timeout: 5000 so validation stays bounded if git stalls on a degraded runner.
    • Kept the existing <=255 length guard.
  • Behavioral coverage

    • Added focused unit coverage for branch names that motivated this fix:
      • accepted: feature/x, release/v1.2+hotfix
      • rejected: foo..bar, main.lock, .foo, foo/.bar, @{-1}, @{-2}
    • Added boundary test: 255-char name accepted, 256-char name rejected.
  • Spec alignment

    • Updated ADR-30071 normative wording from a regex pattern requirement to git check-ref-format refs/heads/<name> semantics.
function isValidBaseBranchName(branchName) {
  if (!branchName || branchName.length > 255) return false;
  // Use refs/heads/<name> to validate as a literal ref, not a branch expression.
  // --branch also accepts @{-N} git expressions; refs/heads/ form correctly rejects them.
  // Fail-closed: if git is unavailable (ENOENT) or times out (ETIMEDOUT), result.error is set
  // and we return false, safely dropping the base branch rather than passing an invalid value.
  const result = spawnSync("git", ["check-ref-format", `refs/heads/${branchName}`], { stdio: "ignore", timeout: 5000 });
  return !result.error && result.status === 0;
}

@github-actions

Copy link
Copy Markdown
Contributor

Hey @Copilot 👋 — thanks for picking up this extract-base-branch regex correctness fix! The issue is clearly scoped and the proposed direction (delegating to git check-ref-format instead of a hand-rolled character class) is the right call.

A few things to address before this is ready for review:

  • No diff yet — the PR is still in WIP state with 0 files changed. The fix lives at the gh-aw codegen layer (the template that emits the Extract base branch from agent output step into compiled *.lock.yml files). Make sure the source template is updated, not just downstream artifacts.
  • Add tests — the issue includes a concrete reproducer showing the divergence between the old regex and git check-ref-format. A test that validates the new step logic against these cases (foo..bar, main.lock, .foo, release/v1.2+hotfix) would prevent regressions and make the fix reviewable.
  • Update the description — once the approach is settled, replace the WIP boilerplate with a short summary of what changed and where in the codebase.

If you'd like a hand completing the implementation, here's a ready-to-use prompt:

Fix the `extract-base-branch` step regex in the gh-aw safe-outputs codegen layer.

Context:
- The step is auto-injected at compile time into *.lock.yml files (e.g. worker-fix.lock.yml)
- Current regex `^[a-zA-Z0-9/_.-]+$` incorrectly accepts `foo..bar`, `main.lock`, `.foo` and rejects valid refs like `release/v1.2+hotfix`
- Issue: github/gh-aw#30749

Tasks:
1. Find the source template that emits the `Extract base branch from agent output` step.
2. Replace the hand-rolled regex check with `git check-ref-format --branch "$BASE_BRANCH"` as the canonical validator.
3. Add or update tests covering:
   a. Valid refs that previously failed: `release/v1.2+hotfix`
   b. Invalid refs that previously passed: `foo..bar`, `main.lock`, `.foo`, `foo.`, `foo/.bar`
   c. A ref that both the old and new check accept: `feature/my-branch`
4. Ensure the length check (`le 255`) is preserved alongside the new git check.

Generated by ✅ Contribution Check ·

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix extract base branch regex to match git check-ref-format semantics safe-outputs: validate extracted base branch with git check-ref-format --branch Jun 18, 2026
Copilot AI requested a review from pelikhan June 18, 2026 06:38
@pelikhan pelikhan marked this pull request as ready for review June 18, 2026 11:12
Copilot AI review requested due to automatic review settings June 18, 2026 11:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Aligns safe-outputs base-branch validation with Git’s canonical ref rules by switching from a regex to git check-ref-format --branch, ensuring workflows neither accept invalid branch names nor reject valid ones (e.g., release/v1.2+hotfix) when extracting base_branch from agent_output.json.

Changes:

  • Replaced regex-based branch validation with git check-ref-format --branch in the extract-base-branch setup script (keeping the 255-char guard).
  • Added focused vitest coverage for previously problematic “valid but regex-rejected” and “regex-safe but Git-invalid” branch names.
  • Updated ADR-30071 normative wording to specify git check-ref-format --branch semantics.
Show a summary per file
File Description
docs/adr/30071-decouple-safe-outputs-base-branch-from-event-context.md Updates the normative spec to require git check-ref-format --branch semantics (plus max length).
actions/setup/js/extract_base_branch_from_agent_output.cjs Implements Git-native validation via git check-ref-format --branch and exports the validator for testing.
actions/setup/js/extract_base_branch_from_agent_output.test.cjs Adds unit tests covering representative accepted/rejected branch-name cases motivating the change.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 3/3 changed files
  • Comments generated: 0

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

No ADR enforcement needed: PR #40001 does not have the 'implementation' label (has_implementation_label=false) and has 0 new lines of code in business logic directories (≤100 threshold). Both enforcement conditions are unmet.

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions github-actions Bot mentioned this pull request Jun 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 85/100 — Excellent

Analyzed 2 test(s): 2 design, 0 implementation, 0 guideline violation(s).

📊 Metrics & Test Classification (2 tests analyzed)
Metric Value
New/modified tests analyzed 2
✅ Design tests (behavioral contracts) 2 (100%)
⚠️ Implementation tests (low value) 0 (0%)
Tests with error/edge cases 1 (50%)
Duplicate test clusters 0
Test inflation detected No
🚨 Coding-guideline violations 0
Test File Classification Issues Detected
it("accepts valid git branch names used in safe outputs") extract_base_branch_from_agent_output.test.cjs:74 ✅ Design
it("rejects invalid git branch names even if they look regex-safe") extract_base_branch_from_agent_output.test.cjs:79 ✅ Design

Go: 0 (*_test.go); JavaScript: 2 (*.test.cjs). Other languages detected but not scored.

Proportional growth: test file +13 lines vs. production +17 lines (ratio 0.76 — no inflation).

Verdict

Check passed. 0% implementation tests (threshold: 30%). Both new tests verify observable contracts of isValidBaseBranchName: one confirms valid git branch names are accepted (including + special char rejected by the old regex), and one confirms that git-invalid names (.., .lock, leading .) are rejected. No mock libraries, no missing build tags, and no assertion-message violations detected.

🧪 Test quality analysis by Test Quality Sentinel ·

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Test Quality Sentinel: 85/100. Test quality is acceptable — 0% of new tests are implementation tests (threshold: 30%).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skills-Based Review 🧠

Applied /diagnose and /tdd — approving with minor suggestions.

📋 Key Themes & Highlights

Key Themes

  • Robustness gap: spawnSync lacks a timeout option; a hung git process would block the step indefinitely (low probability but easy to fix).
  • Missing boundary test: MAX_BRANCH_NAME_LENGTH = 255 is enforced in code but the 255/256 boundary is never exercised by the test suite.
  • Silent fail-closed: git unavailability causes silent branch rejection — correct behavior, but worth a one-line comment for future readers.

Positive Highlights

  • ✅ Root cause properly addressed: regex replaced with git check-ref-format --branch, the canonical validator — eliminates both false accepts (foo..bar, main.lock) and false rejects (release/v1.2+hotfix).
  • ✅ Correct use of --branch flag prevents a leading-hyphen argument from being misinterpreted as a git option (no command-injection risk).
  • isValidBaseBranchName exported and unit-tested, making regression coverage precise and maintainable.
  • ✅ ADR-30071 normative wording updated in lockstep with the implementation — prevents future regex drift.
  • ✅ Fail-closed on subprocess error (!result.error && result.status === 0) is the safe default.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer

return false;
}

const result = spawnSync("git", ["check-ref-format", "--branch", branchName], { stdio: "ignore" });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnose] spawnSync has no timeout option — if git stalls (degraded runner, network-mounted git install), validation will block the step indefinitely.

💡 Suggested fix

Add a timeout (e.g. 5 seconds) so the function stays bounded:

const result = spawnSync("git", ["check-ref-format", "--branch", branchName], {
  stdio: "ignore",
  timeout: 5000,
});

With timeout set, a hung subprocess is killed and result.error will be set (ETIMEDOUT), so the existing !result.error guard safely returns false.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added timeout: 5000 to the spawnSync call in the latest commit.

expect(isValidBaseBranchName("main.lock")).toBe(false);
expect(isValidBaseBranchName(".foo")).toBe(false);
expect(isValidBaseBranchName("foo/.bar")).toBe(false);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The MAX_BRANCH_NAME_LENGTH = 255 invariant is enforced in code but never exercised by a test — the exact boundary (255 valid, 256 rejected) is not confirmed.

💡 Suggested test
it('enforces the 255-character length limit', () => {
  const atLimit   = 'a'.repeat(255);
  const overLimit = 'a'.repeat(256);
  expect(isValidBaseBranchName(atLimit)).toBe(true);
  expect(isValidBaseBranchName(overLimit)).toBe(false);
});

Without a boundary test, a future refactor that changes or removes the guard goes undetected by the suite.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a boundary test in the latest commit: 255-char name accepted, 256-char name rejected.

}

const result = spawnSync("git", ["check-ref-format", "--branch", branchName], { stdio: "ignore" });
return !result.error && result.status === 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnose] When git is not on PATH, result.error is set (ENOENT) and the function returns false, silently dropping the base branch. This is the safe fail-closed behavior, but it is worth a brief comment so the next reader does not treat it as a bug.

💡 Suggested comment
// Fail-closed: if git is unavailable or the ref is invalid, reject the branch.
// A missing git binary sets result.error (ENOENT); a timeout sets it (ETIMEDOUT).
return !result.error && result.status === 0;

The ADR already documents the conservative-rejection intent, but a one-liner here saves a future reader from needing to cross-reference it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added an inline comment in the latest commit explaining ENOENT (missing git) and ETIMEDOUT (timeout) both set result.error, making the guard fail-closed in both cases.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES--branch introduces a correctness regression: git special expressions like @{-1} now pass validation and can reach checkout steps as relative ref expressions, not literal branch names.

Details

Root cause

git check-ref-format --branch accepts @{-N} notation by design (it is a valid branch expression meaning the N-th previously-checked-out branch). The old regex ^[a-zA-Z0-9/_.-]+$ rejected @{-1} because @, {, } are not in the allowed character set — an accidental but correct side-effect. This PR removes that guard.

Verified on the runner:

git check-ref-format --branch "@{-1}"    → exit 0  (PASSES — bug)
git check-ref-format "refs/heads/@{-1}" → exit 1  (rejects — correct)

Fix

Switch to refs/heads/<branchName> instead of --branch. This validates the string as a literal branch name, accepts release/v1.2+hotfix, and still rejects foo..bar, .foo, etc. Also add @{-1} / @{-2} assertions to the test suite to pin the behaviour.

🔎 Code quality review by PR Code Quality Reviewer

return false;
}

const result = spawnSync("git", ["check-ref-format", "--branch", branchName], { stdio: "ignore" });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--branch causes @{-N} git expressions to pass validation, silently allowing values like @{-1} through to core.setOutput where they would be interpreted as relative branch references, not literal names.

💡 Suggested fix

git check-ref-format --branch is designed for branch expressions, not just literal branch names. It explicitly accepts @{-N} notation (e.g. @{-1} = previously-checked-out branch). Confirmed on this runner:

$ git check-ref-format --branch "@{-1}"     → exit 0  ← BUG: passes
$ git check-ref-format "refs/heads/@{-1}"  → exit 1  ← correct: rejects

The old regex ^[a-zA-Z0-9/_.-]+$ accidentally blocked this because @, {, and } were not in the allowed character set. This PR reopens that hole.

Use the full refs/heads/<branchName> form instead — it validates the name as a literal ref without accepting expressions:

const result = spawnSync("git", ["check-ref-format", `refs/heads/${branchName}`], { stdio: "ignore" });

This still accepts release/v1.2+hotfix (→ refs/heads/release/v1.2+hotfix exits 0) and still rejects foo..bar, .foo, main.lock, etc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest commit. Switched to refs/heads/${branchName} — this validates the name as a literal ref and correctly rejects @{-1}, @{-2}, and similar expressions. The --branch form has been removed.

expect(isValidBaseBranchName("release/v1.2+hotfix")).toBe(true);
});

it("rejects invalid git branch names even if they look regex-safe", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing @{-N} test cases: the current test suite never exercises @{-1} or similar git expressions, so the regression in the validator is not caught.

💡 Suggested fix

Add to the "rejects invalid" block:

expect(isValidBaseBranchName("@{-1}")).toBe(false);
expect(isValidBaseBranchName("@{-2}")).toBe(false);

With the current implementation these assertions will fail (confirming the bug — @{-1} passes the --branch validator). After switching to refs/heads/<branchName>, they will correctly pass as expected rejections and prevent future regressions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in the latest commit — @{-1} and @{-2} are now asserted to return false, confirming the refs/heads/ fix closes the gap.

@pelikhan

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher skill

…ent, add boundary tests

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title safe-outputs: validate extracted base branch with git check-ref-format --branch safe-outputs: validate extracted base branch with git check-ref-format refs/heads/<name> Jun 18, 2026
@pelikhan pelikhan merged commit 3093789 into main Jun 18, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants