Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 31 additions & 10 deletions .github/workflows/auto-approve.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,28 +34,49 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh pr review --approve "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}"

# Check for workflow file changes early (to decide whether we can safely use App token for auto-merge)
- name: Check for workflow file changes (use GITHUB_TOKEN fallback to avoid needing workflows:write on App)
id: wf-changes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
pr="${{ github.event.pull_request.number }}"
if gh pr view "$pr" --json files --jq '.files[].path' | grep -q '^\.github/workflows/'; then
echo "changes=true" >> "$GITHUB_OUTPUT"
echo "PR touches .github/workflows/; will use GITHUB_TOKEN for auto-merge (no workflows:write needed on App)"
else
echo "changes=false" >> "$GITHUB_OUTPUT"
fi

- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
if: github.event.pull_request.user.login != 'patchloom-release[bot]'
if: github.event.pull_request.user.login != 'patchloom-release[bot]' && steps.wf-changes.outputs.changes != 'true'
id: app-token
with:
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}

- name: Check for workflow file changes (to avoid App token needing workflows:write)
id: wf-changes
# Strong guard against accidental/automated release PR merges is also
# implemented here (label check below) and via scripts/guard-no-release-merge.sh.
# See AGENTS.md "Release PRs - Strong Guard" section.

- name: Strong guard - detect release-please PRs (autorelease: pending label)
id: release-guard
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
pr="${{ github.event.pull_request.number }}"
if gh pr view "$pr" --json files --jq '.files[].path' | grep -q '^\.github/workflows/'; then
echo "changes=true" >> "$GITHUB_OUTPUT"
echo "PR touches .github/workflows/; will skip auto-merge (App lacks workflows:write)"
labels=$(gh pr view "$pr" --json labels --jq '.labels[].name' || true)
if echo "$labels" | grep -q 'autorelease: pending'; then
echo "is_release_pr=true" >> "$GITHUB_OUTPUT"
echo "Release PR detected by label 'autorelease: pending' - will skip auto-merge (user must explicitly approve merges of release PRs)"
else
echo "changes=false" >> "$GITHUB_OUTPUT"
echo "is_release_pr=false" >> "$GITHUB_OUTPUT"
fi

- name: Enable auto-merge
if: github.event.pull_request.user.login != 'patchloom-release[bot]' && steps.wf-changes.outputs.changes != 'true'
- name: Enable auto-merge (App token when no wf changes; GITHUB_TOKEN fallback otherwise)
if: >-
github.event.pull_request.user.login != 'patchloom-release[bot]' &&
steps.release-guard.outputs.is_release_pr != 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
run: gh pr merge --auto --squash "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}"
41 changes: 41 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,44 @@ All I/O-dependent functions accept an `inputs` object with injectable callbacks
- All relative imports must use `.js` extensions (`from "./foo.js"`, not `from "./foo"`). Required by `moduleResolution: "node16"`.
- All commits require a `Signed-off-by` line (DCO). Use `git commit -s`.
- When adding commands to `package.json`, update the expected count in `test/suite/index.ts`.
- **Branch & PR workflow (never push a branch and stop):** For any trackable work,
after the first `git push` immediately create a draft PR (`gh pr create --draft`).
Continue development with normal `git push` (updates the draft PR + CI).
Only run `gh pr ready <number>` (and enable auto-merge if needed) when the
changes are ready for review/merge. This ensures every pushed branch is
backed by an open (draft) PR from the start. See `~/.grok/skills/owned-repo-gate/SKILL.md`.

## Release PRs - Strong Guard

Release PRs (created by release-please, titled "chore: release ..." or "chore(main): release ...", or labeled `autorelease: pending`) MUST NEVER be merged (with `gh pr merge`, `--auto`, or otherwise) without the user's explicit approval.

Merging a release PR:
- Publishes a new version of the VSIX
- Creates git tags
- Triggers the full release pipeline (Marketplace, Open VSX, attestation bundles)
- The user controls release cadence, not the agent.

### Required procedure (strong guard)

When you encounter a release PR (during triage, gate check, `gh pr list`, or status):

1. Report it clearly: "Release PR #N (vX.Y.Z) is ready to merge."
2. Use the `ask_user_question` tool (or direct question) to ask: "Should I merge it?"
3. **Only after receiving an explicit "yes" (or equivalent affirmative) from the user in this session**, proceed.
4. Before executing any merge command, run the guard:
```
bash scripts/guard-no-release-merge.sh <number>
```
The script will abort with guidance unless `ALLOW_RELEASE_MERGE=yes` is set (only after user yes).
5. If checks pass and user said yes: `gh pr merge <number> --squash` (or let auto if user directed).

This rule was strengthened after an incident where `gh pr merge 144 --auto` (under a broad "merge everything" instruction) resulted in v0.0.5 being published without explicit per-release "yes".

### Defense in depth

- Workflow: `.github/workflows/auto-approve.yml` uses author + label check + wf-changes to never enable `--auto` for release PRs.
- Script: `scripts/guard-no-release-merge.sh` provides a hard runtime guard for shell commands.
- Documentation: This section + global AGENTS.md rule.
- Branch protection + ruleset: still enforces checks, but does not replace user approval for releases.

Never bypass the guard "just this once" or rationalize. Ask every time.
81 changes: 81 additions & 0 deletions scripts/guard-no-release-merge.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Strong guard against merging release-please PRs without explicit user approval.
#
# Usage:
# bash scripts/guard-no-release-merge.sh [PR_NUMBER]
# # or with current branch context (will try to detect open PR)
#
# Exits 0 if safe to merge (non-release PR).
# Exits 1 and prints guidance if release PR detected, unless ALLOW_RELEASE_MERGE=yes.
#
# This is a defense-in-depth guard for agents and humans.
# See AGENTS.md for the full "Release PRs - Strong Guard" policy.
# Per global rules: report the PR, ask user "Should I merge it?", only proceed after explicit "yes".

set -euo pipefail

pr="${1:-}"

if [[ -z "$pr" ]]; then
# Try to detect PR from current context (works if gh pr view succeeds for head)
if pr=$(gh pr view --json number --jq '.number' 2>/dev/null); then
:
else
echo "ERROR: No PR number provided and could not auto-detect current PR."
echo "Usage: $0 <pr-number>"
exit 2
fi
fi

echo "Guard: inspecting PR #$pr for release-please markers..."

title=$(gh pr view "$pr" --json title --jq '.title' 2>/dev/null || echo "")
labels=$(gh pr view "$pr" --json labels --jq '.labels[].name' 2>/dev/null || true)
body=$(gh pr view "$pr" --json body --jq '.body' 2>/dev/null | head -c 500 || true)

is_release=false
reason=""

if echo "$labels" | grep -q 'autorelease: pending'; then
is_release=true
reason="has label 'autorelease: pending'"
elif echo "$title" | grep -qiE '^(chore|release).*release |release v?[0-9]+\.[0-9]+'; then
is_release=true
reason="title looks like release: '$title'"
elif echo "$body" | grep -qi 'release-please'; then
is_release=true
reason="body mentions release-please"
fi

if [[ "$is_release" == "true" ]]; then
echo ""
echo "================================================================"
echo "STRONG GUARD TRIGGERED"
echo "================================================================"
echo "PR #$pr is a release PR ($reason)."
echo "Title: $title"
echo ""
echo "Release PRs (release-please etc) MUST NEVER be merged without the"
echo "user's explicit approval in this chat session."
echo ""
echo " 1. Report: \"Release PR #$pr ($title) is ready to merge.\""
echo " 2. Ask the user using ask_user_question or directly: \"Should I merge it?\""
echo " 3. ONLY proceed after an explicit \"yes\" (or equivalent)."
echo ""
echo "Merging publishes a new version, creates tags, and triggers releases."
echo "The user (not the agent) controls release cadence."
echo ""
echo "To bypass (ONLY after receiving explicit user yes):"
echo " ALLOW_RELEASE_MERGE=yes $0 $pr"
echo "================================================================"
echo ""

if [[ "${ALLOW_RELEASE_MERGE:-}" != "yes" ]]; then
exit 1
else
echo "BYPASS: ALLOW_RELEASE_MERGE=yes detected. Proceeding (user approved)."
fi
fi

echo "Guard OK: PR #$pr does not appear to be a release PR. Safe to consider merge."
exit 0
4 changes: 4 additions & 0 deletions test/unit/initializeProject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ test("classifyAgentsFile detects real content drift", () => {
assert.equal(classifyAgentsFile("# Rules\n- One\n", "# Rules\n- Two\n"), "different");
});

test("classifyAgentsFile + normalize handles trailing ws + CRLF variants (new coverage for #154)", () => {
assert.equal(classifyAgentsFile("# Rules\n- One \n \n", "# Rules\n- One\n"), "up_to_date");
});

test("buildStatusDetails includes workspace readiness context", () => {
const details = buildStatusDetails(
{
Expand Down
10 changes: 5 additions & 5 deletions test/unit/patchloomCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,16 +647,16 @@ describe("managed install end-to-end MCP", { timeout: 120_000 }, async () => {

// Verify the binary is executable
test("managed install produces a runnable binary", async () => {
// Cold start after fresh managed install can take >15s on some runners; use 30s.
const { stdout, stderr } = await execFileAsync(binaryPath, ["--version"], { timeout: 30000 });
// Cold start after fresh managed install can take >30s on some runners/CI; use 60s for robustness.
const { stdout, stderr } = await execFileAsync(binaryPath, ["--version"], { timeout: 60000 });
const output = `${stdout}${stderr}`.trim();
const version = parsePatchloomVersion(output);
assert.ok(version, `should parse version from managed binary: ${output}`);
assert.match(version, /^\d+\.\d+\.\d+/);
});

test("MCP server responds to initialize", async () => {
const child = execFile(binaryPath, ["mcp-server"], { timeout: 15000 });
const child = execFile(binaryPath, ["mcp-server"], { timeout: 60000 });
let stdout = "";
child.stdout!.on("data", (data: Buffer) => { stdout += data.toString(); });

Expand Down Expand Up @@ -690,7 +690,7 @@ describe("managed install end-to-end MCP", { timeout: 120_000 }, async () => {
});

test("MCP server lists available tools", async () => {
const child = execFile(binaryPath, ["mcp-server"], { timeout: 15000 });
const child = execFile(binaryPath, ["mcp-server"], { timeout: 60000 });
let stdout = "";
child.stdout!.on("data", (data: Buffer) => { stdout += data.toString(); });

Expand Down Expand Up @@ -765,7 +765,7 @@ describe("managed install end-to-end MCP", { timeout: 120_000 }, async () => {
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "patchloom-mcp-call-"));
await fs.writeFile(path.join(workDir, "config.json"), '{"port": 3000}\n', "utf8");

const child = execFile(binaryPath, ["mcp-server"], { timeout: 15000, cwd: workDir });
const child = execFile(binaryPath, ["mcp-server"], { timeout: 60000, cwd: workDir });
let stdout = "";
child.stdout!.on("data", (data: Buffer) => { stdout += data.toString(); });

Expand Down
Loading