feat(ci): auto-merge pipeline, org gate, and QA fix hints (#284)

* refactor(core): decompose Core into serviceManager + messageBus (#215)

Extract two focused, unexported components from the Core "god object":

- serviceManager: owns service registry, lifecycle tracking (startables/
  stoppables), and service lock
- messageBus: owns IPC action dispatch, query handling, and task handling

All public API methods on Core become one-line delegation wrappers.
Zero consumer changes — no files outside pkg/framework/core/ modified.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(core): remove unused fields from test struct

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(core): address review feedback from Gemini and Copilot

- Move locked check inside mutex in registerService to fix TOCTOU race
- Add mutex guards to enableLock and applyLock methods
- Replace fmt.Errorf with errors.Join in action() for correct error
  aggregation (consistent with queryAll and lifecycle methods)
- Add TestMessageBus_Action_Bad for error aggregation coverage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ci(workflows): bump host-uk/build from v3 to v4

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ci(workflows): replace Wails build with Go CLI build

The build action doesn't yet support Wails v3. Comment out the GUI
build step and use host-uk/build/actions/setup/go for Go toolchain
setup with a plain `go build` for the CLI binary.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(container): check context before select in Stop to fix flaky test

Stop() now checks ctx.Err() before entering the select block. When a
pre-cancelled context is passed, the select could non-deterministically
choose <-done over <-ctx.Done() if the process had already exited,
causing TestLinuxKitManager_Stop_Good_ContextCancelled to fail on CI.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ci): trim CodeQL matrix to valid languages

Remove javascript-typescript and actions from CodeQL matrix — this
repo contains only Go and Python. Invalid languages blocked SARIF
upload and prevented merge.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(go): add `core go fuzz` command and wire into QA

- New `core go fuzz` command discovers Fuzz* targets and runs them
  with configurable --duration (default 10s per target)
- Fuzz added to default QA checks with 5s burst duration
- Seed fuzz targets for core package: FuzzE (error constructor),
  FuzzServiceRegistration, FuzzMessageDispatch

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ci(codeql): add workflow_dispatch trigger for manual runs

Allows manual triggering of CodeQL when the automatic pull_request
trigger doesn't fire.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ci(codeql): remove workflow in favour of default setup

CodeQL default setup is now enabled via repo settings for go and
python. The workflow-based approach uploaded results as "code quality"
rather than "code scanning", which didn't satisfy the code_scanning
ruleset requirement. Default setup handles this natively.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ci(workflows): add explicit permissions to all workflows

- agent-verify: add issues: write (was missing, writes comments/labels)
- ci: add contents: read (explicit least-privilege)
- coverage: add contents: read (explicit least-privilege)

All workflows now declare permissions explicitly. Repo default is
read-only, so workflows without a block silently lacked write access.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ci(workflows): replace inline logic with org reusable workflow callers

agent-verify.yml and auto-project.yml now delegate to centralised
reusable workflows in host-uk/.github, reducing per-repo duplication.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(ci): auto-merge pipeline, org gate, and QA fix hints

Add auto-merge workflow for org member PRs, external PR gate with
label-based approval, and actionable fix instructions for QA failures.

- auto-merge.yml: enable squash auto-merge for org member PRs
- pr-gate.yml: org-gate check blocks external PRs without label
- cmd_qa.go: add FixHint field, fixHintFor(), extractFailingTest()
- Ruleset: thread resolution, stale review dismissal, 1min merge wait

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-04 14:13:32 +00:00 committed by GitHub
parent b806f4f11c
commit 90531c148d
3 changed files with 130 additions and 0 deletions

40
.github/workflows/auto-merge.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: Auto Merge
on:
pull_request:
types: [opened, reopened, ready_for_review]
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
if: "!github.event.pull_request.draft"
runs-on: ubuntu-latest
steps:
- name: Check org membership and enable auto-merge
uses: actions/github-script@v7
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
with:
script: |
const { owner, repo } = context.repo;
const author = context.payload.pull_request.user.login;
try {
await github.rest.orgs.checkMembershipForUser({
org: owner,
username: author,
});
} catch {
core.info(`${author} is not an org member — skipping auto-merge`);
return;
}
await exec.exec('gh', [
'pr', 'merge', process.env.PR_NUMBER,
'--auto', '--squash',
]);
core.info(`Auto-merge enabled for #${process.env.PR_NUMBER}`);

42
.github/workflows/pr-gate.yml vendored Normal file
View file

@ -0,0 +1,42 @@
name: PR Gate
on:
pull_request_target:
types: [opened, synchronize, reopened, labeled]
permissions:
contents: read
jobs:
org-gate:
runs-on: ubuntu-latest
steps:
- name: Check org membership or approval label
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const author = context.payload.pull_request.user.login;
// Check if author is an org member
try {
await github.rest.orgs.checkMembershipForUser({
org: owner,
username: author,
});
core.info(`${author} is an org member — gate passed`);
return;
} catch {
core.info(`${author} is not an org member — checking for label`);
}
// Check for external-approved label
const labels = context.payload.pull_request.labels.map(l => l.name);
if (labels.includes('external-approved')) {
core.info('external-approved label present — gate passed');
return;
}
core.setFailed(
`External PR from ${author} requires an org member to add the "external-approved" label before merge.`
);

View file

@ -6,6 +6,7 @@ import (
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"time"
@ -147,6 +148,7 @@ type CheckResult struct {
Duration string `json:"duration"`
Error string `json:"error,omitempty"`
Output string `json:"output,omitempty"`
FixHint string `json:"fix_hint,omitempty"`
}
func runGoQA(cmd *cli.Command, args []string) error {
@ -218,6 +220,7 @@ func runGoQA(cmd *cli.Command, args []string) error {
if qaVerbose {
result.Output = output
}
result.FixHint = fixHintFor(check.Name, output)
failed++
if !qaJSON && !qaQuiet {
@ -225,6 +228,9 @@ func runGoQA(cmd *cli.Command, args []string) error {
if qaVerbose && output != "" {
cli.Text(output)
}
if result.FixHint != "" {
cli.Hint("fix", result.FixHint)
}
}
if qaFailFast {
@ -260,6 +266,7 @@ func runGoQA(cmd *cli.Command, args []string) error {
if !qaJSON && !qaQuiet {
cli.Print(" %s Coverage %.1f%% below threshold %.1f%%\n",
cli.ErrorStyle.Render(cli.Glyph(":cross:")), cov, qaThreshold)
cli.Hint("fix", "Run 'core go cov --open' to see uncovered lines, then add tests.")
}
}
}
@ -436,6 +443,47 @@ func buildCheck(name string) QACheck {
}
}
// fixHintFor returns an actionable fix instruction for a given check failure.
func fixHintFor(checkName, output string) string {
switch checkName {
case "format", "fmt":
return "Run 'core go qa fmt --fix' to auto-format."
case "vet":
return "Fix the issues reported by go vet — typically genuine bugs."
case "lint":
return "Run 'core go qa lint --fix' for auto-fixable issues."
case "test":
if name := extractFailingTest(output); name != "" {
return fmt.Sprintf("Run 'go test -run %s -v ./...' to debug.", name)
}
return "Run 'go test -run <TestName> -v ./path/' to debug."
case "race":
return "Data race detected. Add mutex, channel, or atomic to synchronise shared state."
case "bench":
return "Benchmark regression. Run 'go test -bench=. -benchmem' to reproduce."
case "vuln":
return "Run 'govulncheck ./...' for details. Update affected deps with 'go get -u'."
case "sec":
return "Review gosec findings. Common fixes: validate inputs, parameterised queries."
case "fuzz":
return "Add a regression test for the crashing input in testdata/fuzz/<Target>/."
case "docblock":
return "Add doc comments to exported symbols: '// Name does X.' before each declaration."
default:
return ""
}
}
var failTestRe = regexp.MustCompile(`--- FAIL: (\w+)`)
// extractFailingTest parses the first failing test name from go test output.
func extractFailingTest(output string) string {
if m := failTestRe.FindStringSubmatch(output); len(m) > 1 {
return m[1]
}
return ""
}
func runCheckCapture(ctx context.Context, dir string, check QACheck) (string, error) {
// Handle internal checks
if check.Command == "_internal_" {