Skip to content

Commit 2bf4212

Browse files
authored
Merge pull request #41 from xraph/ci/shared-go-ci
ci: run the test matrix through shared go-ci
2 parents 87ff30b + f71e55a commit 2bf4212

6 files changed

Lines changed: 264 additions & 123 deletions

.github/workflows/go.yml

Lines changed: 69 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -13,80 +13,34 @@ permissions:
1313

1414
jobs:
1515
# Build and test
16+
# Gating test legs: ubuntu + macos. Runs through the shared workflow, which
17+
# provisions the Node toolchain the generated-client runtime tests need.
1618
test:
17-
name: Test on ${{ matrix.os }}
18-
runs-on: ${{ matrix.os }}
19-
timeout-minutes: 15
20-
strategy:
21-
fail-fast: false
22-
matrix:
23-
os: [ubuntu-latest, macos-latest, windows-latest]
24-
steps:
25-
- name: Checkout code
26-
uses: actions/checkout@v4
27-
with:
28-
sparse-checkout: |
29-
/*
30-
!docs/
31-
sparse-checkout-cone-mode: false
32-
33-
- name: Set up Go
34-
uses: actions/setup-go@v5
35-
with:
36-
go-version-file: go.mod
37-
check-latest: true
38-
cache-dependency-path: |
39-
**/go.sum
40-
41-
- name: Download dependencies
42-
run: go mod download
43-
44-
- name: Verify dependencies
45-
run: go mod verify
46-
47-
- name: Check go.mod tidiness
48-
if: matrix.os != 'windows-latest'
49-
run: |
50-
go mod tidy
51-
git diff --exit-code go.mod go.sum || (echo "❌ go.mod or go.sum needs tidying" && exit 1)
52-
53-
- name: Build
54-
run: go build -v ./...
55-
56-
- name: Set up Node
57-
uses: actions/setup-node@v4
58-
with:
59-
node-version: '20'
60-
61-
- name: Install TypeScript and esbuild
62-
# esbuild is required by the generated-client runtime tests, which
63-
# bundle an emitted client and execute it under Node. Without it the
64-
# harness falls back to `npx esbuild`, which cannot auto-install
65-
# non-interactively in CI ("npx canceled due to missing packages and
66-
# no YES option") -- those tests then fail rather than skip, and the
67-
# execution coverage they provide is exactly the coverage that catches
68-
# runtime/type mismatches the tsc gate cannot see.
69-
run: npm install -g typescript@5.8.2 esbuild@0.28.1
70-
71-
- name: Run tests
72-
shell: bash
73-
run: |
74-
PKGS=$(go list ./... | grep -v '/bk/')
75-
go test -v -short -race -timeout=10m -coverprofile=coverage.out $PKGS
76-
77-
- name: Upload coverage reports to Codecov
78-
uses: codecov/codecov-action@v5
79-
with:
80-
token: ${{ secrets.CODECOV_TOKEN }}
81-
files: coverage.out
82-
83-
- name: Upload coverage
84-
if: matrix.os == 'ubuntu-latest'
85-
uses: actions/upload-artifact@v4
86-
with:
87-
name: coverage-report
88-
path: coverage.out
89-
retention-days: 7
19+
uses: xraph/workflows/.github/workflows/go-ci.yml@v1
20+
with:
21+
go-versions: '["1.26"]'
22+
os: '["ubuntu-latest","macos-latest"]'
23+
only-test: true
24+
node-version: '20'
25+
npm-global-packages: 'typescript@5.8.2 esbuild@0.28.1'
26+
# `make test` sweeps every module in the repo; CI deliberately tests only
27+
# the root module and leaves the rest to build-all-modules.
28+
prefer-makefile: false
29+
secrets:
30+
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
31+
32+
# Windows is optional and must never block a merge - ci-summary deliberately
33+
# leaves this out of its gate, matching the pre-migration policy.
34+
test-windows:
35+
uses: xraph/workflows/.github/workflows/go-ci.yml@v1
36+
with:
37+
go-versions: '["1.26"]'
38+
os: '["windows-latest"]'
39+
only-test: true
40+
node-version: '20'
41+
npm-global-packages: 'typescript@5.8.2 esbuild@0.28.1'
42+
prefer-makefile: false
43+
coverage: false
9044

9145
# Build and vet ALL submodules (extensions, examples, cmd)
9246
build-all-modules:
@@ -206,8 +160,9 @@ jobs:
206160
args: --timeout=5m --exclude-dirs=bk
207161
continue-on-error: true
208162

163+
# bk/ no longer exists, so the old `grep -v '/bk/'` filter was a no-op.
209164
- name: Run go vet
210-
run: go vet $(go list ./... | grep -v '/bk/')
165+
run: go vet ./...
211166

212167
# Security scanning
213168
security:
@@ -300,41 +255,50 @@ jobs:
300255
ci-summary:
301256
name: CI Summary
302257
runs-on: ubuntu-latest
303-
needs: [test, build-all-modules, lint, security, build-cli]
258+
needs: [test, test-windows, build-all-modules, lint, security, build-cli]
304259
if: always()
305260
steps:
306261
- name: Generate summary
262+
env:
263+
TEST_RESULT: ${{ needs.test.result }}
264+
WINDOWS_RESULT: ${{ needs.test-windows.result }}
265+
MODULES_RESULT: ${{ needs.build-all-modules.result }}
266+
LINT_RESULT: ${{ needs.lint.result }}
267+
SECURITY_RESULT: ${{ needs.security.result }}
268+
CLI_RESULT: ${{ needs.build-cli.result }}
307269
run: |
308-
cat >> $GITHUB_STEP_SUMMARY << EOF
309-
# CI Results Summary
310-
311-
## Job Status
312-
- **Test**: ${{ needs.test.result }} (Windows optional)
313-
- **Build All Modules**: ${{ needs.build-all-modules.result }}
314-
- **Lint**: ${{ needs.lint.result }}
315-
- **Security**: ${{ needs.security.result }}
316-
- **Build CLI**: ${{ needs.build-cli.result }}
317-
318-
## Details
319-
- **Commit**: ${{ github.sha }}
320-
- **Branch**: ${{ github.ref_name }}
321-
- **Triggered by**: ${{ github.event_name }}
322-
- **Run number**: ${{ github.run_number }}
323-
324-
> **Note**: Windows tests are optional and failures won't block CI
325-
326-
EOF
327-
328-
# Allow Windows test failures
329-
if [ "${{ needs.build-all-modules.result }}" != "success" ] || \
330-
[ "${{ needs.lint.result }}" != "success" ] || \
331-
[ "${{ needs.security.result }}" != "success" ] || \
332-
[ "${{ needs.build-cli.result }}" != "success" ]; then
333-
echo "❌ **CI Failed** - Please check the logs above" >> $GITHUB_STEP_SUMMARY
270+
set -uo pipefail
271+
{
272+
echo "# CI Results Summary"
273+
echo
274+
echo "| Job | Result |"
275+
echo "|---|---|"
276+
echo "| Test (ubuntu, macos) | ${TEST_RESULT} |"
277+
echo "| Test (windows, optional) | ${WINDOWS_RESULT} |"
278+
echo "| Build All Modules | ${MODULES_RESULT} |"
279+
echo "| Lint | ${LINT_RESULT} |"
280+
echo "| Security | ${SECURITY_RESULT} |"
281+
echo "| Build CLI | ${CLI_RESULT} |"
282+
} >> "$GITHUB_STEP_SUMMARY"
283+
284+
# Windows is explicitly optional and is NOT part of the gate.
285+
failed=0
286+
for entry in "test:${TEST_RESULT}" "build-all-modules:${MODULES_RESULT}" \
287+
"lint:${LINT_RESULT}" "security:${SECURITY_RESULT}" \
288+
"build-cli:${CLI_RESULT}"; do
289+
name="${entry%%:*}"; status="${entry##*:}"
290+
if [ "$status" != "success" ]; then
291+
echo "::error::Required CI job '$name' did not succeed (result: $status)"
292+
failed=1
293+
fi
294+
done
295+
296+
if [ "$failed" -ne 0 ]; then
297+
echo "**CI failed** - see the job logs above." >> "$GITHUB_STEP_SUMMARY"
334298
exit 1
335-
elif [ "${{ needs.test.result }}" != "success" ]; then
336-
echo "⚠️ **Tests have issues (Windows may be failing)** - Please review logs" >> $GITHUB_STEP_SUMMARY
299+
fi
300+
if [ "$WINDOWS_RESULT" != "success" ]; then
301+
echo "**Passed**, with optional Windows tests failing." >> "$GITHUB_STEP_SUMMARY"
337302
else
338-
echo "**All CI checks passed**" >> $GITHUB_STEP_SUMMARY
303+
echo "**All CI checks passed**" >> "$GITHUB_STEP_SUMMARY"
339304
fi
340-

extension_runnable.go

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"os"
77
"os/exec"
88
"sync"
9-
"syscall"
109
"time"
1110

1211
"github.com/xraph/forge/errors"
@@ -313,9 +312,17 @@ func (e *ExternalAppExtension) Shutdown(ctx context.Context) error {
313312
return nil
314313
}
315314

316-
// Send SIGTERM for graceful shutdown
317-
if err := e.process.Process.Signal(syscall.SIGTERM); err != nil {
318-
e.Logger().Warn("failed to send SIGTERM, forcing kill",
315+
// Ask the process to stop cleanly. Platforms without a graceful stop signal
316+
// (Windows) go straight to termination rather than logging a failure for a
317+
// signal that was never going to be deliverable.
318+
if !gracefulStopSupported {
319+
e.running = false
320+
321+
return e.process.Process.Kill()
322+
}
323+
324+
if err := signalGracefulStop(e.process.Process); err != nil {
325+
e.Logger().Warn("failed to signal graceful stop, forcing kill",
319326
F("name", e.config.Name),
320327
F("error", err),
321328
)
@@ -389,10 +396,16 @@ func (e *ExternalAppExtension) Health(ctx context.Context) error {
389396
return fmt.Errorf("external app %s process is nil", e.config.Name)
390397
}
391398

392-
// On Unix systems, sending signal 0 checks if process exists
393-
if err := e.process.Process.Signal(syscall.Signal(0)); err != nil {
394-
return fmt.Errorf("external app %s process check failed: %w", e.config.Name, err)
395-
}
396-
399+
// The running flag above is the liveness signal. The monitor goroutine owns
400+
// the sole Wait() call and clears the flag under this mutex the moment the
401+
// process exits, so it is authoritative.
402+
//
403+
// This used to additionally probe with Signal(0). That probe failed outright
404+
// on Windows, where signals are unsupported, making Health() permanently
405+
// return an error for every external app. It also did not do what it claimed
406+
// on Unix: a child that has exited but not yet been reaped is a zombie whose
407+
// PID still resolves, so Signal(0) succeeds for it, and once the monitor
408+
// reaps it the running flag is already false. It could not observe a state
409+
// the flag missed.
397410
return nil
398411
}

extension_runnable_helper_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package forge
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/signal"
7+
"strconv"
8+
"syscall"
9+
"testing"
10+
"time"
11+
)
12+
13+
// The external-app tests need real child processes. Shelling out to `sleep` and
14+
// `bash` only works where those exist, which excluded Windows and made the suite
15+
// dependent on whatever happens to be on the runner's PATH.
16+
//
17+
// Instead the test binary re-executes itself. os/exec's own tests use this
18+
// pattern: the child runs TestExternalAppHelperProcess, which dispatches on the
19+
// arguments after "--" and exits without producing test output. It needs no
20+
// external tooling, so it behaves identically on every platform.
21+
22+
const helperProcessEnv = "FORGE_EXTERNAL_APP_HELPER"
23+
24+
// helperCommand returns the command and args that re-invoke this test binary in
25+
// helper mode, plus the environment entry that arms it.
26+
func helperCommand(args ...string) (command string, cmdArgs []string, env []string) {
27+
cmdArgs = append([]string{
28+
"-test.run=^TestExternalAppHelperProcess$",
29+
"--",
30+
}, args...)
31+
32+
return os.Args[0], cmdArgs, []string{helperProcessEnv + "=1"}
33+
}
34+
35+
// TestExternalAppHelperProcess is not a real test. It is the entry point for the
36+
// child processes spawned by the external-app tests; it returns immediately
37+
// unless the parent armed it via helperCommand.
38+
func TestExternalAppHelperProcess(t *testing.T) {
39+
if os.Getenv(helperProcessEnv) != "1" {
40+
return
41+
}
42+
43+
args := helperArgs()
44+
if len(args) == 0 {
45+
fmt.Fprintln(os.Stderr, "helper: no mode given")
46+
os.Exit(2)
47+
}
48+
49+
switch args[0] {
50+
case "sleep":
51+
helperSleep(args[1:])
52+
53+
case "writeenv":
54+
helperWriteEnv(args[1:])
55+
56+
case "trap":
57+
helperTrap(args[1:])
58+
59+
default:
60+
fmt.Fprintf(os.Stderr, "helper: unknown mode %q\n", args[0])
61+
os.Exit(2)
62+
}
63+
64+
// Exit rather than return so the testing framework prints no PASS output
65+
// into the parent's captured stream.
66+
os.Exit(0)
67+
}
68+
69+
// helperArgs returns the arguments following "--".
70+
func helperArgs() []string {
71+
for i, a := range os.Args {
72+
if a == "--" {
73+
return os.Args[i+1:]
74+
}
75+
}
76+
77+
return nil
78+
}
79+
80+
// helperSleep blocks for the given number of seconds.
81+
func helperSleep(args []string) {
82+
seconds := 1.0
83+
84+
if len(args) > 0 {
85+
if v, err := strconv.ParseFloat(args[0], 64); err == nil {
86+
seconds = v
87+
}
88+
}
89+
90+
time.Sleep(time.Duration(seconds * float64(time.Second)))
91+
}
92+
93+
// helperWriteEnv writes the value of the named environment variable to a file,
94+
// followed by a newline. Used to prove ExternalAppConfig.Env reaches the child.
95+
func helperWriteEnv(args []string) {
96+
if len(args) < 2 {
97+
fmt.Fprintln(os.Stderr, "helper: writeenv needs <var> <file>")
98+
os.Exit(2)
99+
}
100+
101+
if err := os.WriteFile(args[1], []byte(os.Getenv(args[0])+"\n"), 0o600); err != nil {
102+
fmt.Fprintf(os.Stderr, "helper: writeenv: %v\n", err)
103+
os.Exit(1)
104+
}
105+
}
106+
107+
// helperTrap waits for SIGTERM and exits cleanly, standing in for a child that
108+
// shuts down gracefully. Unix only — the caller gates on gracefulStopSupported,
109+
// because Windows has no signal for the parent to send.
110+
func helperTrap(_ []string) {
111+
ch := make(chan os.Signal, 1)
112+
signal.Notify(ch, syscall.SIGTERM, os.Interrupt)
113+
114+
select {
115+
case <-ch:
116+
case <-time.After(30 * time.Second):
117+
// Parent went away without signalling; do not linger on the runner.
118+
}
119+
}

extension_runnable_signal_unix.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
//go:build !windows
2+
3+
package forge
4+
5+
import (
6+
"os"
7+
"syscall"
8+
)
9+
10+
// gracefulStopSupported reports whether this platform can ask a child process
11+
// to terminate cleanly rather than killing it outright.
12+
const gracefulStopSupported = true
13+
14+
// signalGracefulStop asks the process to shut down cleanly. On Unix that is
15+
// SIGTERM, which a well-behaved child can trap to flush state before exiting.
16+
func signalGracefulStop(p *os.Process) error {
17+
return p.Signal(syscall.SIGTERM)
18+
}

0 commit comments

Comments
 (0)