diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1e8be8f62..6a4d51f15 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -24,6 +24,11 @@ env: # Pipeline (sequential, fail-fast): # go-test → e2e-test → build-and-publish → deploy → publish-badges # PRs stop after build-and-publish (no GHCR push). Master continues to deploy + badges. +# +# Fork guard: everything with an external side effect (GHCR login/push, release +# upload, staging deploy, badge commits) additionally requires +# github.repository == 'Kpa-clawbot/CoreScope'. On a fork (e.g. dborup/CoreScope) +# only the tests, the local image build and CI artifact uploads run. jobs: # ─────────────────────────────────────────────────────────────── @@ -605,15 +610,15 @@ jobs: echo "Built Go staging image ✅" - name: Set up Docker Buildx - if: github.event_name == 'push' + if: github.event_name == 'push' && github.repository == 'Kpa-clawbot/CoreScope' uses: docker/setup-buildx-action@v3 - name: Set up QEMU (arm64 runtime stage) - if: github.event_name == 'push' + if: github.event_name == 'push' && github.repository == 'Kpa-clawbot/CoreScope' uses: docker/setup-qemu-action@v3 - name: Log in to GHCR - if: github.event_name == 'push' + if: github.event_name == 'push' && github.repository == 'Kpa-clawbot/CoreScope' uses: docker/login-action@v3 with: registry: ghcr.io @@ -621,7 +626,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Extract Docker metadata - if: github.event_name == 'push' + if: github.event_name == 'push' && github.repository == 'Kpa-clawbot/CoreScope' id: docker-meta uses: docker/metadata-action@v5 with: @@ -634,7 +639,7 @@ jobs: type=edge,branch=master - name: Build and push to GHCR - if: github.event_name == 'push' + if: github.event_name == 'push' && github.repository == 'Kpa-clawbot/CoreScope' uses: docker/build-push-action@v6 with: context: . @@ -654,7 +659,8 @@ jobs: # ─────────────────────────────────────────────────────────────── release-artifacts: name: "📦 Release Artifacts" - if: startsWith(github.ref, 'refs/tags/v') + # Fork guard: release uploads only on the upstream repository. + if: startsWith(github.ref, 'refs/tags/v') && github.repository == 'Kpa-clawbot/CoreScope' needs: [go-test] runs-on: ubuntu-latest permissions: @@ -690,9 +696,12 @@ jobs: # ─────────────────────────────────────────────────────────────── deploy: name: "🚀 Deploy Staging" + # Fork guard (last clause): never scheduled on a fork, so no fork run can + # reach the self-hosted staging runner. if: | (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master' + && github.repository == 'Kpa-clawbot/CoreScope' needs: [build-and-publish] runs-on: [self-hosted, meshcore-runner-2] steps: @@ -780,7 +789,8 @@ jobs: # ─────────────────────────────────────────────────────────────── publish: name: "📝 Publish Badges & Summary" - if: github.event_name == 'push' + # Fork guard: badge files are committed to master only on the upstream repository. + if: github.event_name == 'push' && github.repository == 'Kpa-clawbot/CoreScope' needs: [deploy] runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release-fast-path.yml b/.github/workflows/release-fast-path.yml index 86abaf7f2..759f418de 100644 --- a/.github/workflows/release-fast-path.yml +++ b/.github/workflows/release-fast-path.yml @@ -25,6 +25,8 @@ concurrency: jobs: retag-or-fallback: name: "🏷️ Re-tag :edge → :vX.Y.Z (fast) or dispatch deploy.yml (fallback)" + # Fork guard: GHCR re-tag and the deploy.yml dispatch only on the upstream repository. + if: github.repository == 'Kpa-clawbot/CoreScope' runs-on: ubuntu-latest steps: - name: Log in to GHCR diff --git a/cmd/server/fork_guard_workflow_test.go b/cmd/server/fork_guard_workflow_test.go new file mode 100644 index 000000000..7e16ef383 --- /dev/null +++ b/cmd/server/fork_guard_workflow_test.go @@ -0,0 +1,130 @@ +// Tests for the fork side-effect guards in the CI workflows. +// +// These tests gate workflow config (not Go code) by reading the YAML as text, +// following the same "config gate" pattern as release_fast_path_workflow_test.go. +// +// The workflows are shared with the upstream repository Kpa-clawbot/CoreScope, +// where they publish images and releases, deploy staging on a self-hosted +// runner and commit badge files. On a fork those side effects must never run, +// while ordinary CI (tests, the local image build, CI artifact uploads) keeps +// running. Every guard is ANDed with the job's or step's existing condition, so +// upstream behaviour is unchanged. +package main + +import ( + "os" + "regexp" + "strings" + "testing" +) + +const upstreamRepoGuard = "github.repository == 'Kpa-clawbot/CoreScope'" + +func readWorkflow(t *testing.T, rel string) string { + t.Helper() + raw, err := os.ReadFile(rel) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + return string(raw) +} + +// workflowJob returns the text of a top-level job (two-space indented key) +// up to the next top-level job. +func workflowJob(t *testing.T, src, job string) string { + t.Helper() + re := regexp.MustCompile(`(?ms)^ ` + regexp.QuoteMeta(job) + `:[ \t]*\n(.*?)(?:^ [a-zA-Z0-9_-]+:[ \t]*\n|\z)`) + m := re.FindStringSubmatch(src) + if m == nil { + t.Fatalf("job %q not found", job) + } + return m[1] +} + +// jobIf returns the job-level `if:` expression (single or multi-line), or "". +func jobIf(job string) string { + m := regexp.MustCompile(`(?ms)^ if:(.*?)^ [a-zA-Z-]+:`).FindStringSubmatch(job) + if m == nil { + return "" + } + return m[1] +} + +func TestForkGuardOnSideEffectJobs(t *testing.T) { + deploy := readWorkflow(t, deployWorkflowRel) + fastPath := readWorkflow(t, fastPathWorkflowRel) + + cases := []struct { + file, src, job string + keep []string // pre-existing conditions that must survive + }{ + {"deploy.yml", deploy, "release-artifacts", []string{"startsWith(github.ref, 'refs/tags/v')"}}, + {"deploy.yml", deploy, "deploy", []string{ + "(github.event_name == 'push' || github.event_name == 'workflow_dispatch')", + "github.ref == 'refs/heads/master'", + }}, + {"deploy.yml", deploy, "publish", []string{"github.event_name == 'push'"}}, + {"release-fast-path.yml", fastPath, "retag-or-fallback", nil}, + } + for _, c := range cases { + cond := jobIf(workflowJob(t, c.src, c.job)) + if !strings.Contains(cond, upstreamRepoGuard) { + t.Errorf("%s job %q: job-level if must contain %q so it never runs on a fork; got %q", c.file, c.job, upstreamRepoGuard, cond) + } + for _, k := range c.keep { + if !strings.Contains(cond, k) { + t.Errorf("%s job %q: existing condition %q must be kept (guard is ANDed, not a replacement); got %q", c.file, c.job, k, cond) + } + } + } +} + +func TestForkGuardOnPublishSteps(t *testing.T) { + job := workflowJob(t, readWorkflow(t, deployWorkflowRel), "build-and-publish") + if cond := jobIf(job); cond != "" { + t.Errorf("build-and-publish must stay unconditional at job level so the local image build still validates on forks; got if %q", cond) + } + steps := regexp.MustCompile(`(?m)^ - name:`).Split(job, -1)[1:] + publishMarkers := []string{"docker/setup-buildx-action", "docker/setup-qemu-action", "docker/login-action", "docker/metadata-action", "docker/build-push-action"} + guarded, sawLocalBuild := 0, false + for _, step := range steps { + ifLine := regexp.MustCompile(`(?m)^ if:(.*)$`).FindStringSubmatch(step) + cond := "" + if ifLine != nil { + cond = ifLine[1] + } + if strings.Contains(step, "Build Go Docker image (local staging)") { + sawLocalBuild = true + if strings.Contains(cond, "github.repository") { + t.Errorf("local staging image build must not be repository-guarded (it is fork-safe validation); got if %q", cond) + } + } + for _, marker := range publishMarkers { + if strings.Contains(step, marker) { + if !strings.Contains(cond, upstreamRepoGuard) || !strings.Contains(cond, "github.event_name == 'push'") { + t.Errorf("publish step using %s must require both push and %q; got if %q", marker, upstreamRepoGuard, cond) + } + guarded++ + } + } + } + if !sawLocalBuild { + t.Errorf("build-and-publish: local staging image build step not found") + } + if guarded != len(publishMarkers) { + t.Errorf("build-and-publish: expected %d guarded publish steps, found %d", len(publishMarkers), guarded) + } +} + +func TestForkGuardLeavesTestJobsRunning(t *testing.T) { + deploy := readWorkflow(t, deployWorkflowRel) + for _, name := range []string{"go-test", "e2e-test"} { + job := workflowJob(t, deploy, name) + if cond := jobIf(job); cond != "" { + t.Errorf("%s must not gain a job-level condition; got %q", name, cond) + } + if strings.Contains(job, "github.repository") { + t.Errorf("%s must not be repository-guarded: tests and CI artifact uploads run on forks too", name) + } + } +}