diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdb1c157..71ce6e17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ jobs: go: name: Go check (${{ matrix.os }}) runs-on: ${{ matrix.os }} + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -37,7 +38,7 @@ jobs: fi - name: Test - run: go test ./... + run: go test ./... -count=1 -timeout=3m - name: Check Windows test tree cleanliness if: runner.os == 'Windows' @@ -49,9 +50,19 @@ jobs: exit 1 fi + - name: ACP prompt and steering race regression + if: runner.os == 'Linux' + timeout-minutes: 2 + shell: bash + run: | + go test -race ./internal/acp \ + -run 'Test(StartPromptWaitsForPromptDispatchBeforeSteering|ClaudeSteeringFallbackCancelsOldRunAndStartsObservableTurn|CodexSteeringFallbackCancelsOldRunAndStartsObservableTurn)$' \ + -count=20 \ + -timeout=90s + - name: Race if: runner.os == 'Linux' - run: go test -race ./... + run: go test -race ./... -count=1 -timeout=3m - name: Vet run: go vet ./... @@ -103,6 +114,7 @@ jobs: browser-integration: name: Native browser integration runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v4 @@ -123,12 +135,13 @@ jobs: run: | browser_path="$(command -v google-chrome-stable || command -v google-chrome || command -v chromium-browser || command -v chromium || true)" test -n "$browser_path" - xvfb-run -a env AGENTDOCK_BROWSER_EXECUTABLE_PATH="$browser_path" go test -race -tags browser_integration ./internal/tool/browser ./internal/app -count=1 + xvfb-run -a env AGENTDOCK_BROWSER_EXECUTABLE_PATH="$browser_path" go test -race -tags browser_integration ./internal/tool/browser ./internal/app -count=1 -timeout=3m container: name: Container check runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml index 29a22b62..d2bf5c57 100644 --- a/.github/workflows/windows-installer.yml +++ b/.github/workflows/windows-installer.yml @@ -13,6 +13,9 @@ on: - 'go.mod' - 'go.sum' - '.github/workflows/windows-installer.yml' + pull_request: + branches: + - main workflow_dispatch: inputs: test_tag: @@ -24,9 +27,54 @@ permissions: contents: write jobs: + changes: + name: Detect Windows installer changes + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + relevant: ${{ steps.filter.outputs.relevant }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Decide whether installer validation is required + id: filter + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + if [[ "$EVENT_NAME" != "pull_request" ]]; then + echo 'relevant=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + + changed_paths="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- \ + scripts \ + packaging \ + desktop/windows \ + cmd \ + internal \ + go.mod \ + go.sum \ + .github/workflows/windows-installer.yml)" + if [[ -n "$changed_paths" ]]; then + printf 'Windows installer validation required by:\n%s\n' "$changed_paths" + echo 'relevant=true' >> "$GITHUB_OUTPUT" + else + echo 'No Windows installer-relevant changes detected.' + echo 'relevant=false' >> "$GITHUB_OUTPUT" + fi + validate: name: Validate installer on Windows PowerShell 5.1 + needs: changes + if: needs.changes.outputs.relevant == 'true' runs-on: windows-latest + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@v4 @@ -305,7 +353,7 @@ jobs: - name: Build and publish signed Windows test package - if: ${{ inputs.test_tag != '' }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.test_tag != '' }} shell: pwsh env: GH_TOKEN: ${{ github.token }} @@ -393,3 +441,35 @@ jobs: --prerelease ` --title "AgentDock Windows test $env:TEST_TAG" ` --notes "Temporary Windows test build from commit $env:GITHUB_SHA. Uses the existing AgentDock self-signed Authenticode certificate." + + gate: + name: Windows Installer gate + needs: [changes, validate] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Verify installer validation result + shell: bash + env: + CHANGES_RESULT: ${{ needs.changes.result }} + RELEVANT: ${{ needs.changes.outputs.relevant }} + VALIDATE_RESULT: ${{ needs.validate.result }} + run: | + if [[ "$CHANGES_RESULT" != "success" ]]; then + echo "Windows installer change detection failed: $CHANGES_RESULT" >&2 + exit 1 + fi + if [[ "$RELEVANT" == "true" ]]; then + if [[ "$VALIDATE_RESULT" != "success" ]]; then + echo "Windows installer validation did not pass: $VALIDATE_RESULT" >&2 + exit 1 + fi + echo 'Windows installer-relevant changes passed full validation.' + exit 0 + fi + if [[ "$VALIDATE_RESULT" != "skipped" ]]; then + echo "Unexpected Windows installer validation result for an unrelated change: $VALIDATE_RESULT" >&2 + exit 1 + fi + echo 'Windows installer validation not required for this change.' diff --git a/scripts/test/ci_workflow_test.go b/scripts/test/ci_workflow_test.go new file mode 100644 index 00000000..60fababa --- /dev/null +++ b/scripts/test/ci_workflow_test.go @@ -0,0 +1,59 @@ +package scripts + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func readWorkflow(t *testing.T, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", ".github", "workflows", name)) + if err != nil { + t.Fatalf("read workflow %s: %v", name, err) + } + return strings.ReplaceAll(string(data), "\r\n", "\n") +} + +func TestCIWorkflowUsesFreshBoundedGoTests(t *testing.T) { + workflow := readWorkflow(t, "ci.yml") + for _, want := range []string{ + "timeout-minutes: 20", + "go test ./... -count=1 -timeout=3m", + "name: ACP prompt and steering race regression", + "-count=20", + "-timeout=90s", + "go test -race ./... -count=1 -timeout=3m", + "go test -race -tags browser_integration ./internal/tool/browser ./internal/app -count=1 -timeout=3m", + "timeout-minutes: 15", + } { + if !strings.Contains(workflow, want) { + t.Fatalf("CI workflow must keep bounded non-cached validation; missing %q", want) + } + } +} + +func TestWindowsInstallerWorkflowHasAlwaysPresentPullRequestGate(t *testing.T) { + workflow := readWorkflow(t, "windows-installer.yml") + for _, want := range []string{ + "pull_request:\n branches:\n - main", + "name: Detect Windows installer changes", + "fetch-depth: 0", + "git diff --name-only \"$BASE_SHA\" \"$HEAD_SHA\"", + "name: Validate installer on Windows PowerShell 5.1", + "needs: changes", + "if: needs.changes.outputs.relevant == 'true'", + "timeout-minutes: 30", + "name: Windows Installer gate", + "needs: [changes, validate]", + "if: always()", + "CHANGES_RESULT: ${{ needs.changes.result }}", + "VALIDATE_RESULT: ${{ needs.validate.result }}", + "github.event_name == 'workflow_dispatch' && inputs.test_tag != ''", + } { + if !strings.Contains(workflow, want) { + t.Fatalf("Windows Installer workflow must keep a safe pull-request gate; missing %q", want) + } + } +}