From aa5557445ade9147d71963ba6c54de8e6853466f Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:43:57 +0900 Subject: [PATCH 1/9] feat: add InfraDeployment evidence type and workspace scope helper Define infra_deployment JSON shape (Execution block, applied_at) and scope.Matches for workspace include/exclude glob filtering. Co-authored-by: Cursor --- internal/evidence/types.go | 28 ++++++++++++++++++++++++++++ internal/scope/scope.go | 11 +++++++++++ 2 files changed, 39 insertions(+) diff --git a/internal/evidence/types.go b/internal/evidence/types.go index f979b8d..39a35b5 100644 --- a/internal/evidence/types.go +++ b/internal/evidence/types.go @@ -77,6 +77,27 @@ type CIRun struct { URL string `json:"url"` } +type InfraDeployment struct { + SchemaVersion string `json:"schema_version"` + Type string `json:"type"` + Provider string `json:"provider"` + Repository string `json:"repository"` + Workspace string `json:"workspace"` + Environment string `json:"environment"` + CommitSHA string `json:"commit_sha"` + Execution Execution `json:"execution"` + AppliedAt time.Time `json:"applied_at"` +} + +type Execution struct { + CIProvider string `json:"ci_provider"` + Workflow string `json:"workflow"` + RunID string `json:"run_id"` + Status string `json:"status"` + TriggeredBy string `json:"triggered_by"` + URL string `json:"url"` +} + type Evaluation struct { SchemaVersion string `json:"schema_version"` ControlID string `json:"control_id"` @@ -119,6 +140,13 @@ func NewCodeChange() CodeChange { } } +func NewInfraDeployment() InfraDeployment { + return InfraDeployment{ + SchemaVersion: SchemaVersion, + Type: "infra_deployment", + } +} + func NewEvaluation(controlID, resourceType, resourceID, severity string) Evaluation { return Evaluation{ SchemaVersion: SchemaVersion, diff --git a/internal/scope/scope.go b/internal/scope/scope.go index 277a06a..5398402 100644 --- a/internal/scope/scope.go +++ b/internal/scope/scope.go @@ -40,6 +40,17 @@ func excluded(name string, patterns []string) bool { return false } +// Matches reports whether name matches any include pattern and is not excluded. +func Matches(name string, include, exclude []string) bool { + if excluded(name, exclude) { + return false + } + if len(include) == 0 { + return true + } + return matched(name, include) +} + func matched(name string, patterns []string) bool { for _, p := range patterns { ok, _ := filepath.Match(p, name) From 7b9f3e036a7c65a586540d5d5c82d53adb25c2b6 Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:44:02 +0900 Subject: [PATCH 2/9] feat: add infra_deployment evidence and control config Add evidence.infra_deployment settings (apply_workflows, apply_job_names, workspace_job_name_pattern) and controls.infra_deployment.traceability with design-doc defaults. Co-authored-by: Cursor --- internal/config/config.go | 61 +++++++++++++++++++++++++++++++--- internal/config/config_test.go | 9 +++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 29b95e3..5412171 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,8 +42,9 @@ type RepositoryScope struct { } type Evidence struct { - RepoControl EvidenceRepoControl `yaml:"repo_control"` - CodeChange EvidenceCodeChange `yaml:"code_change"` + RepoControl EvidenceRepoControl `yaml:"repo_control"` + CodeChange EvidenceCodeChange `yaml:"code_change"` + InfraDeployment EvidenceInfraDeployment `yaml:"infra_deployment"` } type EvidenceRepoControl struct { @@ -57,9 +58,32 @@ type EvidenceCodeChange struct { Scope *RepositoryScope `yaml:"scope,omitempty"` } +type InfraDeploymentScope struct { + Repositories RepositoryScope `yaml:"repositories"` + Workspaces RepositoryScope `yaml:"workspaces"` +} + +type EvidenceInfraDeployment struct { + Provider string `yaml:"provider"` + Environment string `yaml:"environment"` + Scope *InfraDeploymentScope `yaml:"scope,omitempty"` + ApplyWorkflows []string `yaml:"apply_workflows"` + ApplyJobNames []string `yaml:"apply_job_names"` + WorkspaceJobNamePattern string `yaml:"workspace_job_name_pattern"` +} + type Controls struct { - RepoControl RepoControlControls `yaml:"repo_control"` - CodeChange CodeChangeControls `yaml:"code_change"` + RepoControl RepoControlControls `yaml:"repo_control"` + CodeChange CodeChangeControls `yaml:"code_change"` + InfraDeployment InfraDeploymentControls `yaml:"infra_deployment"` +} + +type InfraDeploymentControls struct { + Traceability InfraDeploymentTraceabilityControls `yaml:"traceability"` +} + +type InfraDeploymentTraceabilityControls struct { + Required *bool `yaml:"required,omitempty"` } type RepoControlControls struct { @@ -172,6 +196,20 @@ func applyDefaults(cfg *Config) { v := 90 cfg.Evaluation.CodeChange.LookbackDays = &v } + + id := &cfg.Evidence.InfraDeployment + if id.Environment == "" { + id.Environment = "production" + } + if id.WorkspaceJobNamePattern == "" { + id.WorkspaceJobNamePattern = `terraform apply \(([^,)]+)` + } + + idc := &cfg.Controls.InfraDeployment.Traceability + if idc.Required == nil { + v := true + idc.Required = &v + } } func (c *Config) GitHubToken() (string, error) { @@ -211,6 +249,21 @@ func (c *Config) RepositoriesFor(domain string) RepositoryScope { if c.Evidence.CodeChange.Scope != nil { return *c.Evidence.CodeChange.Scope } + case "infra_deployment": + if c.Evidence.InfraDeployment.Scope != nil && len(c.Evidence.InfraDeployment.Scope.Repositories.Include) > 0 { + return c.Evidence.InfraDeployment.Scope.Repositories + } } return c.Scope.Defaults.Repositories } + +func (c *Config) WorkspacesForInfraDeployment() RepositoryScope { + if c.Evidence.InfraDeployment.Scope != nil { + return c.Evidence.InfraDeployment.Scope.Workspaces + } + return RepositoryScope{} +} + +func (c *Config) InfraDeploymentConfig() EvidenceInfraDeployment { + return c.Evidence.InfraDeployment +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 64a9a30..34b84c1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -49,4 +49,13 @@ controls: {} if *cfg.Evaluation.CodeChange.LookbackDays != 90 { t.Error("expected lookback_days default 90") } + if cfg.Evidence.InfraDeployment.Environment != "production" { + t.Error("expected infra_deployment.environment default production") + } + if cfg.Evidence.InfraDeployment.WorkspaceJobNamePattern == "" { + t.Error("expected workspace_job_name_pattern default") + } + if *cfg.Controls.InfraDeployment.Traceability.Required != true { + t.Error("expected infra_deployment.traceability.required default true") + } } From 523507a49f02d6ddef9ed604527b9eb6068b4458 Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:44:03 +0900 Subject: [PATCH 3/9] feat: add infra_deployment storage Put and Query Extend storage backend with infra_deployment_evidence upsert and date/repo filtering for postgres and filesystem backends. Co-authored-by: Cursor --- internal/storage/filesystem.go | 60 +++++++++++++++++++++++++++++ internal/storage/filesystem_test.go | 32 +++++++++++++++ internal/storage/postgres.go | 58 ++++++++++++++++++++++++++++ internal/storage/storage.go | 2 + 4 files changed, 152 insertions(+) diff --git a/internal/storage/filesystem.go b/internal/storage/filesystem.go index 5e9e360..c8492d2 100644 --- a/internal/storage/filesystem.go +++ b/internal/storage/filesystem.go @@ -38,6 +38,20 @@ func (f *Filesystem) PutRepoControl(ctx context.Context, records []evidence.Repo return nil } +func (f *Filesystem) PutInfraDeployment(ctx context.Context, records []evidence.InfraDeployment) error { + byDate := make(map[string][]evidence.InfraDeployment) + for _, r := range records { + d := r.AppliedAt.UTC().Format("2006-01-02") + byDate[d] = append(byDate[d], r) + } + for date, batch := range byDate { + if err := f.writeJSONLBatch("infra_deployment", date, toAnySlice(batch)); err != nil { + return err + } + } + return nil +} + func (f *Filesystem) PutCodeChange(ctx context.Context, records []evidence.CodeChange) error { byDate := make(map[string][]evidence.CodeChange) for _, r := range records { @@ -104,6 +118,14 @@ func (f *Filesystem) QueryRepoControl(ctx context.Context, filter Filter) ([]evi return filterRepoControl(all, filter.Repositories), nil } +func (f *Filesystem) QueryInfraDeployment(ctx context.Context, filter Filter) ([]evidence.InfraDeployment, error) { + all, err := f.readInfraDeploymentFile(filter.Date) + if err != nil { + return nil, err + } + return filterInfraDeployment(all, filter.Repositories), nil +} + func (f *Filesystem) QueryCodeChange(ctx context.Context, filter Filter) ([]evidence.CodeChange, error) { all, err := f.readCodeChangeFile(filter.Date) if err != nil { @@ -130,6 +152,24 @@ func (f *Filesystem) readRepoControlFile(date string) ([]evidence.RepoControl, e return result, nil } +func (f *Filesystem) readInfraDeploymentFile(date string) ([]evidence.InfraDeployment, error) { + path := filepath.Join(f.basePath, "infra_deployment", date+".jsonl") + raw, err := f.readJSONLFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var result []evidence.InfraDeployment + for _, item := range raw { + if id, ok := item.(evidence.InfraDeployment); ok { + result = append(result, id) + } + } + return result, nil +} + func (f *Filesystem) readCodeChangeFile(date string) ([]evidence.CodeChange, error) { path := filepath.Join(f.basePath, "code_change", date+".jsonl") raw, err := f.readJSONLFile(path) @@ -255,6 +295,12 @@ func (f *Filesystem) readJSONLFile(path string) ([]any, error) { return nil, err } result = append(result, cc) + case "infra_deployment": + var id evidence.InfraDeployment + if err := json.Unmarshal(raw, &id); err != nil { + return nil, err + } + result = append(result, id) default: var ev evidence.Evaluation if err := json.Unmarshal(raw, &ev); err == nil && ev.ControlID != "" { @@ -279,6 +325,20 @@ func filterRepoControl(items []evidence.RepoControl, repos []string) []evidence. return out } +func filterInfraDeployment(items []evidence.InfraDeployment, repos []string) []evidence.InfraDeployment { + if len(repos) == 0 { + return items + } + set := toSet(repos) + var out []evidence.InfraDeployment + for _, item := range items { + if set[item.Repository] { + out = append(out, item) + } + } + return out +} + func filterCodeChange(items []evidence.CodeChange, repos []string) []evidence.CodeChange { if len(repos) == 0 { return items diff --git a/internal/storage/filesystem_test.go b/internal/storage/filesystem_test.go index 2db0d44..31ad9e2 100644 --- a/internal/storage/filesystem_test.go +++ b/internal/storage/filesystem_test.go @@ -39,3 +39,35 @@ func TestFilesystemPutAndQuery(t *testing.T) { t.Fatalf("got %+v", got) } } + +func TestFilesystemPutAndQueryInfraDeployment(t *testing.T) { + dir := t.TempDir() + fs, err := NewFilesystem(filepath.Join(dir, "evidence")) + if err != nil { + t.Fatal(err) + } + + appliedAt := time.Date(2026, 6, 5, 16, 5, 0, 0, time.UTC) + id := evidence.InfraDeployment{ + SchemaVersion: "v1", + Type: "infra_deployment", + Provider: "terraform", + Repository: "platform-infra", + Workspace: "prod-gke", + AppliedAt: appliedAt, + Execution: evidence.Execution{URL: "https://example.com/runs/1"}, + } + + ctx := context.Background() + if err := fs.PutInfraDeployment(ctx, []evidence.InfraDeployment{id}); err != nil { + t.Fatal(err) + } + + got, err := fs.QueryInfraDeployment(ctx, Filter{Date: "2026-06-05"}) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Workspace != "prod-gke" { + t.Fatalf("got %+v", got) + } +} diff --git a/internal/storage/postgres.go b/internal/storage/postgres.go index a333d40..d7da1e8 100644 --- a/internal/storage/postgres.go +++ b/internal/storage/postgres.go @@ -39,6 +39,24 @@ func (p *Postgres) PutRepoControl(ctx context.Context, records []evidence.RepoCo return nil } +func (p *Postgres) PutInfraDeployment(ctx context.Context, records []evidence.InfraDeployment) error { + for _, r := range records { + payload, err := json.Marshal(r) + if err != nil { + return err + } + _, err = p.pool.Exec(ctx, ` + INSERT INTO infra_deployment_evidence (repository, workspace, applied_at, payload) + VALUES ($1, $2, $3, $4) + ON CONFLICT (repository, workspace, applied_at) DO UPDATE SET payload = EXCLUDED.payload + `, r.Repository, r.Workspace, r.AppliedAt, payload) + if err != nil { + return fmt.Errorf("upsert infra_deployment %s#%s: %w", r.Repository, r.Workspace, err) + } + } + return nil +} + func (p *Postgres) PutCodeChange(ctx context.Context, records []evidence.CodeChange) error { for _, r := range records { payload, err := json.Marshal(r) @@ -81,6 +99,30 @@ func (p *Postgres) QueryRepoControl(ctx context.Context, filter Filter) ([]evide return scanRepoControl(rows) } +func (p *Postgres) QueryInfraDeployment(ctx context.Context, filter Filter) ([]evidence.InfraDeployment, error) { + query := `SELECT payload FROM infra_deployment_evidence WHERE 1=1` + args := []any{} + argN := 1 + + if filter.Date != "" { + query += fmt.Sprintf(` AND applied_at::date = $%d::date`, argN) + args = append(args, filter.Date) + argN++ + } + if len(filter.Repositories) > 0 { + query += fmt.Sprintf(` AND repository = ANY($%d)`, argN) + args = append(args, filter.Repositories) + } + + rows, err := p.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + return scanInfraDeployment(rows) +} + func (p *Postgres) QueryCodeChange(ctx context.Context, filter Filter) ([]evidence.CodeChange, error) { query := `SELECT payload FROM code_change_evidence WHERE 1=1` args := []any{} @@ -172,6 +214,22 @@ func scanRepoControl(rows rowScanner) ([]evidence.RepoControl, error) { return result, rows.Err() } +func scanInfraDeployment(rows rowScanner) ([]evidence.InfraDeployment, error) { + var result []evidence.InfraDeployment + for rows.Next() { + var payload []byte + if err := rows.Scan(&payload); err != nil { + return nil, err + } + var id evidence.InfraDeployment + if err := json.Unmarshal(payload, &id); err != nil { + return nil, err + } + result = append(result, id) + } + return result, rows.Err() +} + func scanCodeChange(rows rowScanner) ([]evidence.CodeChange, error) { var result []evidence.CodeChange for rows.Next() { diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 60a8323..b5498e6 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -16,8 +16,10 @@ type Filter struct { type Backend interface { PutRepoControl(ctx context.Context, records []evidence.RepoControl) error PutCodeChange(ctx context.Context, records []evidence.CodeChange) error + PutInfraDeployment(ctx context.Context, records []evidence.InfraDeployment) error QueryRepoControl(ctx context.Context, filter Filter) ([]evidence.RepoControl, error) QueryCodeChange(ctx context.Context, filter Filter) ([]evidence.CodeChange, error) + QueryInfraDeployment(ctx context.Context, filter Filter) ([]evidence.InfraDeployment, error) QueryCodeChangeForJoin(ctx context.Context, repos, shas []string, lookbackDays int) ([]evidence.CodeChange, error) PutEvaluations(ctx context.Context, records []evidence.Evaluation) error } From 3aa95681ebc76ac676063b3ee4bc4c306d6e0573 Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:44:04 +0900 Subject: [PATCH 4/9] feat: add code_change join helper for deployment traceability Join deployments to code_change by repository + commit_sha, picking the newest merged_at when multiple candidates exist (CM-006/007). Co-authored-by: Cursor --- internal/evaluate/join.go | 26 ++++++++++++++++++++++++++ internal/evaluate/join_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 internal/evaluate/join.go create mode 100644 internal/evaluate/join_test.go diff --git a/internal/evaluate/join.go b/internal/evaluate/join.go new file mode 100644 index 0000000..edb6b77 --- /dev/null +++ b/internal/evaluate/join.go @@ -0,0 +1,26 @@ +package evaluate + +import ( + "github.com/keyskey/kao/internal/evidence" +) + +type JoinKey struct { + Repository string + CommitSHA string +} + +// JoinCodeChange maps repository+commit_sha to the code_change with the newest merged_at. +func JoinCodeChange(changes []evidence.CodeChange) map[JoinKey]evidence.CodeChange { + best := make(map[JoinKey]evidence.CodeChange) + for _, cc := range changes { + if cc.Repository == "" || cc.CommitSHA == "" { + continue + } + key := JoinKey{Repository: cc.Repository, CommitSHA: cc.CommitSHA} + existing, ok := best[key] + if !ok || cc.MergedAt.After(existing.MergedAt) { + best[key] = cc + } + } + return best +} diff --git a/internal/evaluate/join_test.go b/internal/evaluate/join_test.go new file mode 100644 index 0000000..87bb2ea --- /dev/null +++ b/internal/evaluate/join_test.go @@ -0,0 +1,29 @@ +package evaluate + +import ( + "testing" + "time" + + "github.com/keyskey/kao/internal/evidence" +) + +func TestJoinCodeChangePicksNewestMergedAt(t *testing.T) { + older := evidence.CodeChange{ + Repository: "platform-infra", + CommitSHA: "abc", + PRNumber: 1, + MergedAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), + } + newer := evidence.CodeChange{ + Repository: "platform-infra", + CommitSHA: "abc", + PRNumber: 2, + MergedAt: time.Date(2026, 6, 3, 0, 0, 0, 0, time.UTC), + } + + joined := JoinCodeChange([]evidence.CodeChange{older, newer}) + got := joined[JoinKey{Repository: "platform-infra", CommitSHA: "abc"}] + if got.PRNumber != 2 { + t.Fatalf("got PR %d, want 2", got.PRNumber) + } +} From 946359d5795e1cf3ed738f4ca3328b5b32a813ba Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:44:10 +0900 Subject: [PATCH 5/9] feat: implement CM-007 infra_deployment evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evaluate Required Chain (infra_deployment → code_change → author → approvals → execution.url) using QueryCodeChangeForJoin and the shared join helper. Wire into evaluate runner and --infra-deployment files flag. Co-authored-by: Cursor --- internal/evaluate/command.go | 1 + internal/evaluate/evaluate_test.go | 50 ++++++++++++++++++ internal/evaluate/infra_deployment.go | 69 +++++++++++++++++++++++++ internal/evaluate/runner.go | 74 +++++++++++++++++++++++++-- 4 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 internal/evaluate/infra_deployment.go diff --git a/internal/evaluate/command.go b/internal/evaluate/command.go index 2c0b9ba..1d8ccd0 100644 --- a/internal/evaluate/command.go +++ b/internal/evaluate/command.go @@ -26,6 +26,7 @@ func NewCommand() *cobra.Command { cmd.Flags().StringVar(&opts.RepoControlFile, "repo-control", "", "repo_control JSONL file (--input files)") cmd.Flags().StringArrayVar(&opts.CodeChangeFiles, "code-change", nil, "code_change JSONL file (--input files, repeatable)") + cmd.Flags().StringArrayVar(&opts.InfraDeploymentFiles, "infra-deployment", nil, "infra_deployment JSONL file (--input files, repeatable)") return cmd } diff --git a/internal/evaluate/evaluate_test.go b/internal/evaluate/evaluate_test.go index 9009207..03beff2 100644 --- a/internal/evaluate/evaluate_test.go +++ b/internal/evaluate/evaluate_test.go @@ -70,6 +70,55 @@ func TestEvaluateCodeChangeCM008Fail(t *testing.T) { } } +func TestEvaluateInfraDeploymentCM007Pass(t *testing.T) { + cfg := &config.Config{} + applyTestDefaults(cfg) + + id := evidence.InfraDeployment{ + Repository: "platform-infra", + Workspace: "prod-gke", + CommitSHA: "def456", + AppliedAt: time.Date(2026, 6, 5, 16, 5, 0, 0, time.UTC), + Execution: evidence.Execution{URL: "https://github.com/org/platform-infra/actions/runs/1"}, + } + cc := evidence.CodeChange{ + Repository: "platform-infra", + PRNumber: 10, + CommitSHA: "def456", + Author: "alice", + ApprovalCount: 1, + MergedAt: time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC), + } + join := map[JoinKey]evidence.CodeChange{ + {Repository: "platform-infra", CommitSHA: "def456"}: cc, + } + + results := EvaluateAll(cfg, nil, nil, []evidence.InfraDeployment{id}, join, id.AppliedAt) + cm007 := findResult(results, "CM-007") + if cm007 == nil || cm007.Status != evidence.StatusPassed { + t.Fatalf("CM-007 should pass, got %+v", cm007) + } +} + +func TestEvaluateInfraDeploymentCM007JoinFail(t *testing.T) { + cfg := &config.Config{} + applyTestDefaults(cfg) + + id := evidence.InfraDeployment{ + Repository: "platform-infra", + Workspace: "prod-gke", + CommitSHA: "missing", + AppliedAt: time.Date(2026, 6, 5, 16, 5, 0, 0, time.UTC), + Execution: evidence.Execution{URL: "https://github.com/org/platform-infra/actions/runs/1"}, + } + + results := EvaluateAll(cfg, nil, nil, []evidence.InfraDeployment{id}, map[JoinKey]evidence.CodeChange{}, id.AppliedAt) + cm007 := findResult(results, "CM-007") + if cm007 == nil || cm007.Status != evidence.StatusFailed { + t.Fatalf("CM-007 should fail, got %+v", cm007) + } +} + func applyTestDefaults(cfg *config.Config) { trueVal := true falseVal := false @@ -82,6 +131,7 @@ func applyTestDefaults(cfg *config.Config) { cfg.Controls.RepoControl.Codeowners.Required = &trueVal cfg.Controls.CodeChange.CI.RequirePass = &trueVal cfg.Controls.CodeChange.Approver.MustBeAuthorized = &trueVal + cfg.Controls.InfraDeployment.Traceability.Required = &trueVal } func findResult(results []evidence.Evaluation, controlID string) *evidence.Evaluation { diff --git a/internal/evaluate/infra_deployment.go b/internal/evaluate/infra_deployment.go new file mode 100644 index 0000000..8ce2bf9 --- /dev/null +++ b/internal/evaluate/infra_deployment.go @@ -0,0 +1,69 @@ +package evaluate + +import ( + "fmt" + "time" + + "github.com/keyskey/kao/internal/config" + "github.com/keyskey/kao/internal/evidence" +) + +func evaluateInfraDeployment(cfg *config.Config, id evidence.InfraDeployment, cc *evidence.CodeChange, evaluatedAt time.Time) []evidence.Evaluation { + if !*cfg.Controls.InfraDeployment.Traceability.Required { + return nil + } + + resourceID := infraDeploymentResourceID(id) + refs := []evidence.EvidenceRef{{ + Type: "infra_deployment", + ResourceID: resourceID, + }} + + ev := evidence.NewEvaluation("CM-007", "infra_deployment", resourceID, evidence.SeverityCritical) + ev.EvidenceRefs = refs + ev.EvaluatedAt = evaluatedAt + + if cc == nil { + ev.Status = evidence.StatusFailed + ev.Expected = "code_change joined by repository + commit_sha" + ev.Actual = "no matching code_change" + ev.Reason = "infra_deployment → code_change join failed" + return []evidence.Evaluation{ev} + } + + refs = append(refs, evidence.EvidenceRef{ + Type: "code_change", + ResourceID: fmt.Sprintf("%s#%d", cc.Repository, cc.PRNumber), + }) + ev.EvidenceRefs = refs + + requiredReviews := *cfg.Controls.RepoControl.BranchProtection.RequiredReviews + + switch { + case cc.Author == "": + ev.Status = evidence.StatusFailed + ev.Expected = "code_change.author present" + ev.Actual = "author missing" + ev.Reason = "infra_deployment → code_change → author missing" + case cc.ApprovalCount < requiredReviews: + ev.Status = evidence.StatusFailed + ev.Expected = fmt.Sprintf("approval_count >= %d", requiredReviews) + ev.Actual = fmt.Sprintf("approval_count == %d", cc.ApprovalCount) + ev.Reason = "infra_deployment → code_change → approvals insufficient" + case id.Execution.URL == "": + ev.Status = evidence.StatusFailed + ev.Expected = "execution.url present" + ev.Actual = "execution.url missing" + ev.Reason = "infra_deployment → execution.url missing" + default: + ev.Status = evidence.StatusPassed + ev.Expected = "traceability chain complete" + ev.Actual = "repository+sha join, author, approvals, execution.url present" + } + + return []evidence.Evaluation{ev} +} + +func infraDeploymentResourceID(id evidence.InfraDeployment) string { + return fmt.Sprintf("%s#%s@%s", id.Repository, id.Workspace, id.AppliedAt.UTC().Format(time.RFC3339)) +} diff --git a/internal/evaluate/runner.go b/internal/evaluate/runner.go index a04c8ce..2dae22f 100644 --- a/internal/evaluate/runner.go +++ b/internal/evaluate/runner.go @@ -22,8 +22,9 @@ type Options struct { Output string Format string - RepoControlFile string - CodeChangeFiles []string + RepoControlFile string + CodeChangeFiles []string + InfraDeploymentFiles []string } func Run(ctx context.Context, opts Options) error { @@ -35,6 +36,8 @@ func Run(ctx context.Context, opts Options) error { evaluatedAt := time.Now().UTC() var repoControls []evidence.RepoControl var codeChanges []evidence.CodeChange + var infraDeployments []evidence.InfraDeployment + var joinChanges map[JoinKey]evidence.CodeChange switch opts.Input { case "", "storage": @@ -54,6 +57,14 @@ func Run(ctx context.Context, opts Options) error { if err != nil { return err } + infraDeployments, err = backend.QueryInfraDeployment(ctx, filter) + if err != nil { + return err + } + joinChanges, err = loadJoinChanges(ctx, backend, cfg, infraDeployments) + if err != nil { + return err + } case "files": repoControls, err = readRepoControlFile(opts.RepoControlFile) if err != nil { @@ -66,16 +77,31 @@ func Run(ctx context.Context, opts Options) error { } codeChanges = append(codeChanges, changes...) } + for _, path := range opts.InfraDeploymentFiles { + deployments, err := readInfraDeploymentFile(path) + if err != nil { + return err + } + infraDeployments = append(infraDeployments, deployments...) + } + joinChanges = JoinCodeChange(codeChanges) default: return fmt.Errorf("unsupported input: %q", opts.Input) } - results := EvaluateAll(cfg, repoControls, codeChanges, evaluatedAt) + results := EvaluateAll(cfg, repoControls, codeChanges, infraDeployments, joinChanges, evaluatedAt) return writeResults(ctx, cfg, opts, results) } -func EvaluateAll(cfg *config.Config, repoControls []evidence.RepoControl, codeChanges []evidence.CodeChange, evaluatedAt time.Time) []evidence.Evaluation { +func EvaluateAll( + cfg *config.Config, + repoControls []evidence.RepoControl, + codeChanges []evidence.CodeChange, + infraDeployments []evidence.InfraDeployment, + joinChanges map[JoinKey]evidence.CodeChange, + evaluatedAt time.Time, +) []evidence.Evaluation { var results []evidence.Evaluation for _, rc := range repoControls { results = append(results, evaluateRepoControl(cfg, rc, evaluatedAt)...) @@ -84,9 +110,45 @@ func EvaluateAll(cfg *config.Config, repoControls []evidence.RepoControl, codeCh rc := findRepoControlForCodeChange(repoControls, cc) results = append(results, evaluateCodeChange(cfg, cc, rc, evaluatedAt)...) } + for _, id := range infraDeployments { + key := JoinKey{Repository: id.Repository, CommitSHA: id.CommitSHA} + var cc *evidence.CodeChange + if joined, ok := joinChanges[key]; ok { + cc = &joined + } + results = append(results, evaluateInfraDeployment(cfg, id, cc, evaluatedAt)...) + } return results } +func loadJoinChanges(ctx context.Context, backend storage.Backend, cfg *config.Config, infraDeployments []evidence.InfraDeployment) (map[JoinKey]evidence.CodeChange, error) { + if len(infraDeployments) == 0 { + return map[JoinKey]evidence.CodeChange{}, nil + } + + repos := make([]string, 0, len(infraDeployments)) + shas := make([]string, 0, len(infraDeployments)) + seenRepo := make(map[string]bool) + seenSHA := make(map[string]bool) + for _, id := range infraDeployments { + if id.Repository != "" && !seenRepo[id.Repository] { + seenRepo[id.Repository] = true + repos = append(repos, id.Repository) + } + if id.CommitSHA != "" && !seenSHA[id.CommitSHA] { + seenSHA[id.CommitSHA] = true + shas = append(shas, id.CommitSHA) + } + } + + lookback := *cfg.Evaluation.CodeChange.LookbackDays + changes, err := backend.QueryCodeChangeForJoin(ctx, repos, shas, lookback) + if err != nil { + return nil, err + } + return JoinCodeChange(changes), nil +} + func writeResults(ctx context.Context, cfg *config.Config, opts Options, results []evidence.Evaluation) error { switch opts.Output { case "", "storage": @@ -143,6 +205,10 @@ func readCodeChangeFile(path string) ([]evidence.CodeChange, error) { return readJSONLFile[evidence.CodeChange](path) } +func readInfraDeploymentFile(path string) ([]evidence.InfraDeployment, error) { + return readJSONLFile[evidence.InfraDeployment](path) +} + func readJSONLFile[T any](path string) ([]T, error) { f, err := os.Open(path) if err != nil { From aea0734aac2f94d914a85d38d7d531ca65ec7a36 Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:44:11 +0900 Subject: [PATCH 6/9] feat: add github provider for infra_deployment collection Detect Terraform apply runs via apply_workflows and apply_job_names prefix match, extract workspace from matrix job names, and emit infra_deployment evidence per successful apply job. Co-authored-by: Cursor --- internal/provider/github/infra_deployment.go | 215 ++++++++++++++++++ .../provider/github/infra_deployment_test.go | 49 ++++ 2 files changed, 264 insertions(+) create mode 100644 internal/provider/github/infra_deployment.go create mode 100644 internal/provider/github/infra_deployment_test.go diff --git a/internal/provider/github/infra_deployment.go b/internal/provider/github/infra_deployment.go new file mode 100644 index 0000000..8dc9c76 --- /dev/null +++ b/internal/provider/github/infra_deployment.go @@ -0,0 +1,215 @@ +package github + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + gogh "github.com/google/go-github/v62/github" + "github.com/keyskey/kao/internal/config" + "github.com/keyskey/kao/internal/evidence" + "github.com/keyskey/kao/internal/scope" +) + +func (c *Client) CollectInfraDeployment(ctx context.Context, repo, date string, cfg *config.Config) ([]evidence.InfraDeployment, error) { + idCfg := cfg.InfraDeploymentConfig() + wsScope := cfg.WorkspacesForInfraDeployment() + + pattern, err := regexp.Compile(idCfg.WorkspaceJobNamePattern) + if err != nil { + return nil, fmt.Errorf("compile workspace_job_name_pattern: %w", err) + } + + targetDay, err := time.Parse("2006-01-02", date) + if err != nil { + return nil, fmt.Errorf("parse date %q: %w", date, err) + } + prevDay := targetDay.AddDate(0, 0, -1) + + owner := c.org + workflowPaths := make(map[int64]string) + + var result []evidence.InfraDeployment + opts := &gogh.ListWorkflowRunsOptions{ + Status: "completed", + ListOptions: gogh.ListOptions{PerPage: 100}, + } + created := fmt.Sprintf("%s..%s", prevDay.Format("2006-01-02"), date) + opts.Created = created + + for { + runs, resp, err := c.api.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("list workflow runs %s: %w", repo, err) + } + + for _, run := range runs.WorkflowRuns { + if !runSucceeded(run) { + continue + } + + workflowPath, err := c.workflowPath(ctx, owner, repo, run, workflowPaths) + if err != nil { + return nil, err + } + if !workflowMatches(workflowPath, idCfg.ApplyWorkflows) { + continue + } + + records, err := c.infraDeploymentsFromRun(ctx, owner, repo, run, workflowPath, date, idCfg, pattern, wsScope) + if err != nil { + return nil, err + } + result = append(result, records...) + } + + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + return result, nil +} + +func (c *Client) workflowPath(ctx context.Context, owner, repo string, run *gogh.WorkflowRun, cache map[int64]string) (string, error) { + workflowID := run.GetWorkflowID() + if path, ok := cache[workflowID]; ok { + return path, nil + } + wf, _, err := c.api.Actions.GetWorkflowByID(ctx, owner, repo, workflowID) + if err != nil { + return "", fmt.Errorf("get workflow %d: %w", workflowID, err) + } + path := wf.GetPath() + cache[workflowID] = path + return path, nil +} + +func (c *Client) infraDeploymentsFromRun( + ctx context.Context, + owner, repo string, + run *gogh.WorkflowRun, + workflowPath, date string, + idCfg config.EvidenceInfraDeployment, + pattern *regexp.Regexp, + wsScope config.RepositoryScope, +) ([]evidence.InfraDeployment, error) { + jobs, _, err := c.api.Actions.ListWorkflowJobs(ctx, owner, repo, run.GetID(), &gogh.ListWorkflowJobsOptions{ + Filter: "latest", + ListOptions: gogh.ListOptions{PerPage: 100}, + }) + if err != nil { + return nil, fmt.Errorf("list workflow jobs run %d: %w", run.GetID(), err) + } + + var result []evidence.InfraDeployment + for _, job := range jobs.Jobs { + if !jobSucceeded(job) { + continue + } + if !jobNameMatches(job.GetName(), idCfg.ApplyJobNames) { + continue + } + + workspace, err := extractWorkspace(job.GetName(), pattern) + if err != nil { + fmt.Fprintf(os.Stderr, "infra-deployment %s run %d job %q: %v\n", repo, run.GetID(), job.GetName(), err) + continue + } + if !scope.Matches(workspace, wsScope.Include, wsScope.Exclude) { + continue + } + + appliedAt := job.GetCompletedAt().Time + if appliedAt.UTC().Format("2006-01-02") != date { + continue + } + + triggeredBy := "" + if run.Actor != nil && run.Actor.Login != nil { + triggeredBy = *run.Actor.Login + } + + id := evidence.NewInfraDeployment() + id.Provider = idCfg.Provider + if id.Provider == "" { + id.Provider = "terraform" + } + id.Repository = repo + id.Workspace = workspace + id.Environment = idCfg.Environment + id.CommitSHA = job.GetHeadSHA() + id.AppliedAt = appliedAt.UTC() + id.Execution = evidence.Execution{ + CIProvider: "github_actions", + Workflow: filepath.Base(workflowPath), + RunID: strconv.FormatInt(run.GetID(), 10), + Status: "succeeded", + TriggeredBy: triggeredBy, + URL: run.GetHTMLURL(), + } + result = append(result, id) + } + return result, nil +} + +func runSucceeded(run *gogh.WorkflowRun) bool { + if strings.ToLower(run.GetStatus()) != "completed" { + return false + } + conclusion := strings.ToLower(run.GetConclusion()) + return conclusion == "success" +} + +func jobSucceeded(job *gogh.WorkflowJob) bool { + if strings.ToLower(job.GetStatus()) != "completed" { + return false + } + conclusion := strings.ToLower(job.GetConclusion()) + return conclusion == "success" +} + +func workflowMatches(path string, patterns []string) bool { + if len(patterns) == 0 { + return false + } + base := filepath.Base(path) + for _, p := range patterns { + if p == path || p == base { + return true + } + if strings.HasSuffix(path, p) { + return true + } + if ok, _ := filepath.Match(p, base); ok { + return true + } + if ok, _ := filepath.Match(p, path); ok { + return true + } + } + return false +} + +func jobNameMatches(name string, prefixes []string) bool { + for _, p := range prefixes { + if strings.HasPrefix(name, p) { + return true + } + } + return false +} + +func extractWorkspace(jobName string, pattern *regexp.Regexp) (string, error) { + matches := pattern.FindStringSubmatch(jobName) + if len(matches) < 2 || strings.TrimSpace(matches[1]) == "" { + return "", fmt.Errorf("workspace not found in job name") + } + return strings.TrimSpace(matches[1]), nil +} diff --git a/internal/provider/github/infra_deployment_test.go b/internal/provider/github/infra_deployment_test.go new file mode 100644 index 0000000..77f4c41 --- /dev/null +++ b/internal/provider/github/infra_deployment_test.go @@ -0,0 +1,49 @@ +package github + +import ( + "regexp" + "testing" +) + +func TestExtractWorkspace(t *testing.T) { + pattern := regexp.MustCompile(`terraform apply \(([^,)]+)`) + + tests := []struct { + job string + want string + }{ + {"terraform apply (prod-gke)", "prod-gke"}, + {"terraform apply (prod-gke, asia-northeast1)", "prod-gke"}, + {"terraform apply (prod-network, us-central1)", "prod-network"}, + } + + for _, tt := range tests { + got, err := extractWorkspace(tt.job, pattern) + if err != nil { + t.Fatalf("%q: %v", tt.job, err) + } + if got != tt.want { + t.Fatalf("%q: got %q want %q", tt.job, got, tt.want) + } + } +} + +func TestJobNameMatches(t *testing.T) { + prefixes := []string{"terraform apply"} + if !jobNameMatches("terraform apply (prod-gke)", prefixes) { + t.Fatal("expected prefix match") + } + if jobNameMatches("terraform plan", prefixes) { + t.Fatal("expected no match") + } +} + +func TestWorkflowMatches(t *testing.T) { + patterns := []string{"terraform-apply.yml"} + if !workflowMatches(".github/workflows/terraform-apply.yml", patterns) { + t.Fatal("expected workflow path match") + } + if workflowMatches("ci.yml", patterns) { + t.Fatal("expected no match") + } +} From da4e8ba23687560d76d8a17c5ab97b20b9fc3ef4 Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:44:12 +0900 Subject: [PATCH 7/9] feat: add collect infra-deployment command and daily step Expose kao collect infra-deployment and run it as step 4 in kao run daily before evaluate. Co-authored-by: Cursor --- internal/collect/command.go | 19 ++++++- internal/collect/infra_deployment.go | 77 ++++++++++++++++++++++++++++ internal/run/command.go | 2 +- internal/run/daily.go | 4 ++ 4 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 internal/collect/infra_deployment.go diff --git a/internal/collect/command.go b/internal/collect/command.go index 4ac4446..e1b12a6 100644 --- a/internal/collect/command.go +++ b/internal/collect/command.go @@ -42,17 +42,34 @@ func NewCommand() *cobra.Command { }, } - for _, c := range []*cobra.Command{cmd, repoControlCmd, codeChangeCmd} { + infraDeploymentCmd := &cobra.Command{ + Use: "infra-deployment", + Short: "Collect infrastructure deployment evidence for a date", + RunE: func(cmd *cobra.Command, args []string) error { + return RunInfraDeployment(context.Background(), Options{ + ConfigPath: configPath, + Repositories: repositories, + Output: output, + Date: date, + }) + }, + } + + for _, c := range []*cobra.Command{cmd, repoControlCmd, codeChangeCmd, infraDeploymentCmd} { c.PersistentFlags().StringVar(&configPath, "config", "", "path to kao.yaml") } repoControlCmd.Flags().StringVar(&output, "output", "storage", "output destination: storage, -, or file path") codeChangeCmd.Flags().StringVar(&output, "output", "storage", "output destination: storage, -, or file path") + infraDeploymentCmd.Flags().StringVar(&output, "output", "storage", "output destination: storage, -, or file path") codeChangeCmd.Flags().StringVar(&date, "date", "", "target date (YYYY-MM-DD, UTC)") + infraDeploymentCmd.Flags().StringVar(&date, "date", "", "target date (YYYY-MM-DD, UTC)") repoControlCmd.Flags().StringArrayVar(&repositories, "repository", nil, "limit to repository (repeatable)") codeChangeCmd.Flags().StringArrayVar(&repositories, "repository", nil, "limit to repository (repeatable)") + infraDeploymentCmd.Flags().StringArrayVar(&repositories, "repository", nil, "limit to repository (repeatable)") cmd.AddCommand(repoControlCmd) cmd.AddCommand(codeChangeCmd) + cmd.AddCommand(infraDeploymentCmd) return cmd } diff --git a/internal/collect/infra_deployment.go b/internal/collect/infra_deployment.go new file mode 100644 index 0000000..2e92d29 --- /dev/null +++ b/internal/collect/infra_deployment.go @@ -0,0 +1,77 @@ +package collect + +import ( + "context" + "fmt" + "os" + + "github.com/keyskey/kao/internal/evidence" + gh "github.com/keyskey/kao/internal/provider/github" +) + +func RunInfraDeployment(ctx context.Context, opts Options) error { + if opts.Date == "" { + return fmt.Errorf("--date is required for infra-deployment collection") + } + + cfg, err := loadConfig(opts.ConfigPath) + if err != nil { + return err + } + + client, err := gh.New(cfg) + if err != nil { + return err + } + + repos := repoList(cfg, "infra_deployment", opts.Repositories) + if len(repos) == 0 { + return fmt.Errorf("no repositories to collect") + } + + backend, writer, err := resolveOutput(cfg, opts.Output) + if err != nil { + return err + } + if writer != nil { + defer func() { + if f, ok := writer.(*os.File); ok && f != os.Stdout { + f.Close() + } + }() + } + + var records []evidence.InfraDeployment + var failures []error + + for _, repo := range repos { + deployments, err := client.CollectInfraDeployment(ctx, repo, opts.Date, cfg) + if err != nil { + failures = append(failures, fmt.Errorf("%s: %w", repo, err)) + continue + } + records = append(records, deployments...) + } + + if writer != nil { + items := make([]any, len(records)) + for i, r := range records { + items[i] = r + } + if err := writeJSONL(writer, items); err != nil { + return err + } + } else if backend != nil && len(records) > 0 { + if err := backend.PutInfraDeployment(ctx, records); err != nil { + return err + } + } + + if len(failures) > 0 { + for _, f := range failures { + fmt.Fprintf(os.Stderr, "collect infra-deployment: %v\n", f) + } + return fmt.Errorf("%d repository(s) failed", len(failures)) + } + return nil +} diff --git a/internal/run/command.go b/internal/run/command.go index 0f4a8e0..a649088 100644 --- a/internal/run/command.go +++ b/internal/run/command.go @@ -18,7 +18,7 @@ func NewCommand() *cobra.Command { dailyCmd := &cobra.Command{ Use: "daily", - Short: "Run daily collection and evaluation (GitHub evidence only in MVP)", + Short: "Run daily collection and evaluation", RunE: func(cmd *cobra.Command, args []string) error { return RunDaily(context.Background(), DailyOptions{ ConfigPath: configPath, diff --git a/internal/run/daily.go b/internal/run/daily.go index 533d122..557aeb6 100644 --- a/internal/run/daily.go +++ b/internal/run/daily.go @@ -36,6 +36,10 @@ func RunDaily(ctx context.Context, opts DailyOptions) error { return fmt.Errorf("code-change collect: %w", err) } + if err := collect.RunInfraDeployment(ctx, collectOpts); err != nil { + return fmt.Errorf("infra-deployment collect: %w", err) + } + evalOpts := evaluate.Options{ ConfigPath: opts.ConfigPath, Date: date, From faeecf25cf070251cec43d77346b33cc1b101966 Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:44:14 +0900 Subject: [PATCH 8/9] test: add infra_deployment fixture data Add sample matrix-apply evidence for files-mode evaluate testing. Co-authored-by: Cursor --- testdata/infra_deployment.jsonl | 1 + 1 file changed, 1 insertion(+) create mode 100644 testdata/infra_deployment.jsonl diff --git a/testdata/infra_deployment.jsonl b/testdata/infra_deployment.jsonl new file mode 100644 index 0000000..f9eb506 --- /dev/null +++ b/testdata/infra_deployment.jsonl @@ -0,0 +1 @@ +{"schema_version":"v1","type":"infra_deployment","provider":"terraform","repository":"platform-infra","workspace":"prod-gke","environment":"production","commit_sha":"def4567890abcdef4567890abcdef4567890ab","execution":{"ci_provider":"github_actions","workflow":"terraform-apply.yml","run_id":"87654321","status":"succeeded","triggered_by":"alice","url":"https://github.com/org/platform-infra/actions/runs/87654321"},"applied_at":"2026-06-05T16:05:00Z"} From 4dbe264805e4e227fa5895d6f8d21152daf3d2ba Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:44:14 +0900 Subject: [PATCH 9/9] =?UTF-8?q?docs:=20document=20matrix=20job=20name=20wo?= =?UTF-8?q?rkspace=20extraction=20in=20=C2=A714?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarify that workspace is parsed from GHA matrix job name suffix, not workflow_dispatch inputs. Co-authored-by: Cursor --- docs/design-doc.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design-doc.md b/docs/design-doc.md index 7e0df3b..b290f30 100644 --- a/docs/design-doc.md +++ b/docs/design-doc.md @@ -643,7 +643,7 @@ GitHub Actions 上で実行された Terraform apply の証跡(v1)。`eviden 1. `scope.repositories` の各リポジトリについて、対象日に完了した CI run を列挙する 2. workflow 名 / job 名が `apply_workflows` / `apply_job_names` に一致する run を apply 実行とみなす -3. run に紐づく commit SHA、実行者、URL、workspace(run パラメータまたは環境変数から取得)を記録する +3. マッチした apply job ごとに commit SHA、実行者、URL、workspace を記録する。workspace は GHA matrix job 名から取得する(共有 workflow で apply job 名が固定でも、GitHub が matrix 次元を `(dim1, dim2, ...)` 形式で suffix する)。`workspace` は先頭次元とし、KAO は `workspace_job_name_pattern`(デフォルト: `terraform apply \(([^,)]+)`)で抽出する ```json {