diff --git a/cmd/releasekit/ci_identity_test.go b/cmd/releasekit/ci_identity_test.go new file mode 100644 index 0000000..1f1bb47 --- /dev/null +++ b/cmd/releasekit/ci_identity_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli" +) + +func TestCIDeliveryRejectsModuleTagMismatchBeforePreparation(t *testing.T) { + for _, binary := range []bool{false, true} { + name := "page-only" + if binary { + name = "binary" + } + t.Run(name, func(t *testing.T) { + repository := newDeliveryBuildRepository(t) + writeRepositoryFile(t, repository, "child/go.mod", "module example.com/tagged-app/child\n\ngo 1.26.0\n") + writeRepositoryFile(t, repository, "child/docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nChild release.\n") + cliGitRun(t, repository, "add", "child") + cliGitRun(t, repository, "commit", "-m", "add child module") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("mismatched module tag reached forge: %s", r.URL.Path) + http.Error(w, "unexpected request", http.StatusInternalServerError) + })) + defer server.Close() + settings := cli.Settings{ + Product: "Example Product", ModuleDir: "child", + Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}, + } + if binary { + settings.Build = &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}} + } + writeDeliveryProfile(t, repository, settings) + commit := pushTaggedCLIRepository(t, repository) + t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") + shimDir := t.TempDir() + marker := filepath.Join(shimDir, "go-invoked") + if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("RELEASEKIT_GO_MARKER", marker) + t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH")) + var stdout, stderr bytes.Buffer + status := run(context.Background(), []string{"ci-deliver", "--repo", repository, "--prepared", "--commit", commit, "--expected-module", "child", "v1.2.3"}, &stdout, &stderr) + if status != 1 || !strings.Contains(stderr.String(), `expected delivery tag "v1.2.3"`) || !strings.Contains(stderr.String(), `selected module tag "child/v1.2.3"`) { + t.Fatalf("status=%d stderr=%q, want module tag mismatch", status, stderr.String()) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("dependency/build work ran before tag comparison: %v", err) + } + }) + } +} diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index f15636f..1869463 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -291,6 +291,15 @@ func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stder fmt.Fprintf(stderr, "profile module selection: %v\n", err) return 2 } + identity, err := releaseidentity.FromCommitted(ctx, invocation.Repository, settings.ModuleDir, invocation.Version, invocation.Commit) + if err != nil { + fmt.Fprintf(stderr, "resolve CI release identity: %v\n", err) + return 1 + } + if invocation.ExpectedTag != identity.Tag { + fmt.Fprintf(stderr, "expected delivery tag %q does not match selected module tag %q\n", invocation.ExpectedTag, identity.Tag) + return 1 + } destination, status := deliveryDestination(invocation.Repository, settings, stderr) if status != 0 { return status diff --git a/docs/cli.md b/docs/cli.md index f86af72..9e86c31 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -244,9 +244,11 @@ verifies the tag, HEAD, and clean worktree. With `--prepared`, it performs those checks read-only against the existing checkout and does not fetch or check out. In either mode, it loads the profile from the verified source. `--expected-module` optionally checks that the profile selects the module chosen by a launcher; -module selection itself remains profile-owned. The command validates explicit -forge selection and resolves the configured token. For binary profiles, it -performs a read-only repository preflight before downloading application +module selection itself remains profile-owned. The command compares the selected +module's derived release tag with the full event tag before forge access or +dependency preparation, then validates explicit forge selection and resolves +the configured token. For binary profiles, it performs a read-only repository +preflight before downloading application dependencies; delivery performs a fresh check after dependency preparation. Source-only profiles rely on delivery's automatic preflight and skip dependency downloads and binary builds. diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index d4f12be..10dd5ac 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -197,7 +197,15 @@ cleanup; this finding does not require removing plans now. ## 5. Bind CI release identity before online preflight and preparation — P2 -Status: open. +Status: complete. + +**Completed:** CI now derives the selected module identity from the verified +event commit and checks the complete tag before credential loading, forge +requests, or dependency preparation. The optional launcher-module guard and +delivery's later source/preflight verification retain their responsibilities. +A focused page-only/binary regression covers a matching launcher selection +with a mismatched derived tag and verifies no forge or Go-tool work. That test +and existing valid CI/preflight/managed-delivery tests passed. **Evidence:** [CI adapter](../../cmd/releasekit/main.go), especially `runCIDelivery` and `checkExpectedModule`, and the existing identity/tag checks