diff --git a/.github/workflows/coderepute-report.yml b/.github/workflows/coderepute-report.yml new file mode 100644 index 0000000..03b7d88 --- /dev/null +++ b/.github/workflows/coderepute-report.yml @@ -0,0 +1,82 @@ +# Canonical reusable workflow: the attested entry point for CodeRepute +# reports. +# +# When a consumer calls this workflow (pinned to a tag), the Sigstore +# certificate's job_workflow_ref names THIS file in THIS repository at the +# pinned version. That makes the action identity machine-checkable: +# +# gh attestation verify report.json --repo \ +# --signer-workflow grkanitz/CodeRepute/.github/workflows/coderepute-report.yml +# +# A fork (someorg/CodeRepute) carries a different job_workflow_ref and +# fails that check. The composite action source is checked out at +# github.job_workflow_sha — exactly the commit of this workflow file — so +# the binary cannot diverge from the pinned version. +# +# Consumer usage: +# +# jobs: +# report: +# permissions: +# contents: read +# pull-requests: read +# id-token: write +# attestations: write +# uses: grkanitz/CodeRepute/.github/workflows/coderepute-report.yml@v0.1.0 +# with: +# repos: your-org/your-repo +# subject: some-username +name: coderepute-report + +on: + workflow_call: + inputs: + repos: + description: Repositories to cover, owner/name (comma-separated). + type: string + required: true + subject: + description: GitHub username the report is about. + type: string + required: true + window-days: + description: Report window ending now, in days. + type: string + required: false + default: "365" + artifact-name: + description: Name of the uploaded workflow artifact. + type: string + required: false + default: coderepute-report + outputs: + attestation-url: + description: URL of the stored attestation. + value: ${{ jobs.report.outputs.attestation-url }} + +jobs: + report: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + id-token: write + attestations: write + outputs: + attestation-url: ${{ steps.run.outputs.attestation-url }} + steps: + - name: Check out the pinned action source + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + repository: grkanitz/CodeRepute + ref: ${{ github.job_workflow_sha }} + path: .coderepute-action + + - name: Produce and attest report + id: run + uses: ./.coderepute-action + with: + repos: ${{ inputs.repos }} + subject: ${{ inputs.subject }} + window-days: ${{ inputs.window-days }} + artifact-name: ${{ inputs.artifact-name }} diff --git a/.github/workflows/demo-report.yml b/.github/workflows/demo-report.yml new file mode 100644 index 0000000..d3548a6 --- /dev/null +++ b/.github/workflows/demo-report.yml @@ -0,0 +1,57 @@ +# Live demo of the trust chain: run the CodeRepute action against this +# repository and attest the resulting report.json. +# +# Triggered manually (workflow_dispatch). Exercises both consumption +# paths: the composite action directly, and the canonical reusable +# workflow (the path whose Sigstore identity survives the +# --signer-workflow fork check). +name: demo-report + +on: + workflow_dispatch: + inputs: + repos: + description: Repositories to cover, owner/name (comma-separated). + required: false + default: grkanitz/CodeRepute + subject: + description: GitHub username the report is about. + required: false + default: grkanitz + +jobs: + # Path 1: the composite action used directly from this ref. The + # attestation identity is THIS workflow. + demo-action: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Produce and attest report + id: report + uses: ./ + with: + repos: ${{ inputs.repos || 'grkanitz/CodeRepute' }} + subject: ${{ inputs.subject || 'grkanitz' }} + artifact-name: coderepute-report-action + - name: Show attestation URL + run: echo "attestation-url=${{ steps.report.outputs.attestation-url }}" + + # Path 2: the canonical reusable workflow (same commit, local call). + # The attestation identity is coderepute-report.yml — what consumers + # check with --signer-workflow. + demo-reusable: + permissions: + contents: read + pull-requests: read + id-token: write + attestations: write + uses: ./.github/workflows/coderepute-report.yml + with: + repos: ${{ inputs.repos || 'grkanitz/CodeRepute' }} + subject: ${{ inputs.subject || 'grkanitz' }} + artifact-name: coderepute-report-reusable diff --git a/README.md b/README.md index eac621a..2122bd6 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,33 @@ A GitHub token is read from `-token` or the `GITHUB_TOKEN` environment variable. Local runs always emit a verification block with status `unverified`; cryptographic attestation only exists in CI. +## Running in CI with attestation + +Use the composite action, pinned to a tagged version: + +```yaml +jobs: + report: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + id-token: write + attestations: write + steps: + - uses: grkanitz/CodeRepute@v0.1.0 + with: + repos: your-org/your-repo + subject: some-username +``` + +This produces `report.json` + `report.html` as workflow artifacts and a +Sigstore attestation over `report.json`. The strongest trust chain is the +canonical reusable workflow, whose identity a verifier can check +mechanically. See [docs/verification.md](docs/verification.md) for both +patterns, the exact `gh attestation verify` commands, and what passing +proves. + ## License Apache-2.0 diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..b3dc252 --- /dev/null +++ b/action.yml @@ -0,0 +1,108 @@ +# CodeRepute composite action: build the CLI from this action's own pinned +# source, produce report.json + report.html, attest report.json with a +# Sigstore/OIDC artifact attestation, and upload both as artifacts. +# +# The calling workflow MUST grant: +# +# permissions: +# contents: read +# pull-requests: read # default-token runs: the CLI lists PRs +# id-token: write # OIDC identity for Sigstore signing +# attestations: write # store the attestation on the repository +# +# Pin this action to a tagged version (grkanitz/CodeRepute@vX.Y.Z). +# See docs/verification.md for what the attestation proves and how +# consumers verify it. +name: CodeRepute Report +description: >- + Produce a CodeRepute collaboration report from GitHub API metadata and + attest the report JSON with a Sigstore artifact attestation. +author: grkanitz + +inputs: + repos: + description: Repositories to cover, owner/name (comma-separated for several). + required: true + subject: + description: GitHub username the report is about. + required: true + token: + description: GitHub token used to read repository metadata. + required: false + default: ${{ github.token }} + window-days: + description: Report window ending now, in days. + required: false + default: "365" + out: + description: Output directory for report.json and report.html. + required: false + default: coderepute-report + attest: + description: >- + Attest report.json with actions/attest-build-provenance. Requires + id-token:write and attestations:write. Set "false" to skip (the + report then stays useful but is not independently verifiable). + required: false + default: "true" + artifact-name: + description: Name of the uploaded workflow artifact. + required: false + default: coderepute-report + +outputs: + report-json: + description: Path to the generated report.json. + value: ${{ steps.generate.outputs.report-json }} + report-html: + description: Path to the generated report.html. + value: ${{ steps.generate.outputs.report-html }} + attestation-url: + description: URL of the stored attestation (empty when attest=false). + value: ${{ steps.attest.outputs.attestation-url }} + +runs: + using: composite + steps: + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: ${{ github.action_path }}/go.mod + cache: false + + - name: Build coderepute from the pinned action source + shell: bash + working-directory: ${{ github.action_path }} + run: go build -trimpath -o "$RUNNER_TEMP/coderepute" ./cmd/coderepute + + - name: Generate report + id: generate + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.token }} + CR_REPOS: ${{ inputs.repos }} + CR_SUBJECT: ${{ inputs.subject }} + CR_WINDOW_DAYS: ${{ inputs.window-days }} + CR_OUT: ${{ inputs.out }} + run: | + "$RUNNER_TEMP/coderepute" \ + -repo "$CR_REPOS" \ + -subject "$CR_SUBJECT" \ + -window-days "$CR_WINDOW_DAYS" \ + -out "$CR_OUT" + echo "report-json=$CR_OUT/report.json" >> "$GITHUB_OUTPUT" + echo "report-html=$CR_OUT/report.html" >> "$GITHUB_OUTPUT" + + - name: Attest report.json + id: attest + if: ${{ inputs.attest == 'true' }} + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: ${{ steps.generate.outputs.report-json }} + + - name: Upload report artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.artifact-name }} + path: ${{ inputs.out }} + if-no-files-found: error diff --git a/cmd/coderepute/main.go b/cmd/coderepute/main.go index 0a77e0b..273d423 100644 --- a/cmd/coderepute/main.go +++ b/cmd/coderepute/main.go @@ -95,6 +95,9 @@ func run(args []string, getenv func(string) string, stderr io.Writer) int { result := metrics.Compute(activity) r := report.Build(activity, &result.Collaboration, &result.Cadence, time.Now(), report.WithTokenScopeClass(github.ClassifyToken(*token, activity.TokenScope))) + if v := report.CIVerification(getenv); v != nil { + r.Verification = v + } if err := r.Validate(); err != nil { fmt.Fprintf(stderr, "coderepute: built an invalid report: %v\n", err) return 1 diff --git a/cmd/coderepute/main_test.go b/cmd/coderepute/main_test.go index 6d6f41f..b5a66a3 100644 --- a/cmd/coderepute/main_test.go +++ b/cmd/coderepute/main_test.go @@ -162,6 +162,48 @@ func TestRunTrimsRepoListWhitespace(t *testing.T) { } } +func TestRunInGitHubActionsUpgradesVerification(t *testing.T) { + srv := fixtureServer(t) + out := t.TempDir() + + env := map[string]string{ + "GITHUB_ACTIONS": "true", + "GITHUB_REPOSITORY": "acme/widgets", + "GITHUB_WORKFLOW_REF": "acme/widgets/.github/workflows/report.yml@refs/heads/main", + "GITHUB_RUN_ID": "9000000001", + "GITHUB_SERVER_URL": "https://github.com", + } + var stderr bytes.Buffer + code := run([]string{ + "-repo", "acme/widgets", + "-subject", "octocat", + "-token", "test-token", + "-out", out, + "-api-base", srv.URL, + }, func(key string) string { return env[key] }, &stderr) + if code != 0 { + t.Fatalf("run exited %d: %s", code, stderr.String()) + } + + rawJSON, err := os.ReadFile(filepath.Join(out, "report.json")) + if err != nil { + t.Fatalf("report.json not written: %v", err) + } + r, err := report.Parse(rawJSON) + if err != nil { + t.Fatalf("report.json invalid: %v", err) + } + if r.Verification.Status != report.StatusVerified { + t.Errorf("CI run verification = %q, want verified", r.Verification.Status) + } + if want := "acme/widgets/.github/workflows/report.yml@refs/heads/main"; r.Verification.WorkflowRef != want { + t.Errorf("workflow_ref = %q, want %q", r.Verification.WorkflowRef, want) + } + if r.Verification.Attestation == nil { + t.Error("CI run verification block carries no attestation pointer") + } +} + func TestRunRejectsMissingArgs(t *testing.T) { tests := []struct { name string diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..ba3394c --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,146 @@ +# Verifying a CodeRepute report + +A CodeRepute report is only as trustworthy as the process that produced +it. The CLI computes statistics from GitHub API metadata, so anyone could +edit the JSON afterwards — unless the run happened in CI and left a +cryptographic trail. This document explains that trail: what gets +attested, what a passing verification proves, and what it does not. + +## The trust chain + +1. A workflow in the subject's organization runs the CodeRepute action + **pinned to a tagged version** (e.g. `grkanitz/CodeRepute@v0.1.0`). +2. The action builds the CLI from that pinned source, writes + `report.json` and `report.html`, and attests `report.json` with + [`actions/attest-build-provenance`](https://github.com/actions/attest-build-provenance) + — a Sigstore signature over the file's SHA-256 digest, bound to the + workflow's OIDC identity and stored on the producing repository. +3. The report's own `verification` block records the producing identity + (`provider`, `repository`, `workflow_ref`, `run_id`, `run_url`) and a + pointer to the attestation, including the exact verify command. + +Reports produced outside CI carry an explicit +`"verification": {"status": "unverified"}` block. The CLI never claims +more than its environment proves. + +The `verification` block itself is part of the report — untrusted input +until checked. It tells a verifier *where to look* (repository, workflow +ref, the verify command); only running `gh attestation verify` proves +anything. A report whose block says `verified` but whose attestation +does not exist or does not match fails verification. + +## Verifying + +Verification is **two checks**. Both must pass. + +### 1. Verify the attestation + +```sh +gh attestation verify report.json --repo +``` + +where `` is the repository the report claims in +`verification.repository`. This proves: + +- **Integrity** — `report.json` is bit-for-bit the file that was attested; + any post-run edit fails verification. +- **Origin** — the attestation was created by a GitHub Actions workflow + running in that repository (and therefore that org), signed via + GitHub's OIDC issuer. Nobody outside that repo's CI can mint it. +- **Run identity** — the verified provenance names the exact workflow + ref, commit SHA, and run that produced the file. Inspect it with + `--format json`. + +### 2. Check the producing workflow identity against the canonical action + +Step 1 proves *which workflow* produced the report — not that the +workflow ran *unmodified CodeRepute*. A fork of this repository with +doctored metrics could attest its own output and pass step 1 in its own +org. The workflow identity must therefore be matched against the +canonical action. Two ways, strongest first: + +**a. Canonical reusable workflow (machine-checkable).** Consumers who run +reports via the reusable workflow + +```yaml +jobs: + report: + permissions: + contents: read + pull-requests: read + id-token: write + attestations: write + uses: grkanitz/CodeRepute/.github/workflows/coderepute-report.yml@v0.1.0 + with: + repos: your-org/your-repo + subject: some-username +``` + +get a Sigstore certificate whose `job_workflow_ref` names +`grkanitz/CodeRepute/.github/workflows/coderepute-report.yml` at the +pinned tag. Verify with: + +```sh +gh attestation verify report.json --repo \ + --signer-workflow grkanitz/CodeRepute/.github/workflows/coderepute-report.yml +``` + +A modified fork (`someorg/CodeRepute`) produces a different +`job_workflow_ref` and **fails this command**. The reusable workflow +checks out the action source at `github.job_workflow_sha` — exactly the +commit of the pinned workflow file — so the binary cannot diverge from +the tag being verified. + +**b. Direct composite action (manual identity check).** When a consumer +workflow uses the action directly (`uses: grkanitz/CodeRepute@v0.1.0`), +the attested identity is the *consumer's* workflow. Then: + +```sh +gh attestation verify report.json --repo --format json \ + --jq '.[].verificationResult.statement.predicate.buildDefinition.externalParameters.workflow' +``` + +returns the producing workflow's repository, ref, and commit. Inspect +that workflow file **at that commit** and confirm it references +`grkanitz/CodeRepute` at a published tag — not a fork, not a mutable +branch. A workflow that used a fork or a modified copy fails this +inspection. + +## What passing proves — and what it does not + +Passing both checks proves: + +- the bytes of `report.json` are unchanged since the attested run; +- the report was produced inside CI of the named org/repo; +- the producing workflow is (a) the canonical CodeRepute reusable + workflow at a pinned version, or (b) a consumer workflow you have + inspected and found to pin the canonical action. + +It does **not** prove: + +- that the coverage is complete — read the report's `coverage` block for + the repos, window, and token scope the run could see; +- that the underlying GitHub data is honest (e.g. activity manufactured + before the run); +- anything about reports whose `verification.status` is `unverified` — + those are honest local runs with no chain at all. + +## Pinned-version convention + +- Consumers reference the action or reusable workflow at a **tagged + release** (`@v0.1.0`), never `@main`. +- Tags are immutable once published; a new behavior means a new tag. +- Verifiers match the workflow identity against the canonical repository + `grkanitz/CodeRepute` and a tagged ref, per the checks above. + +## Platform requirements + +- Sigstore artifact attestations via the public Sigstore instance + require a **public** repository (or GitHub Enterprise Cloud for + private repositories). On a private repo without Enterprise, the + attest step fails and no attestation is produced. +- The calling workflow must grant `id-token: write` and + `attestations: write` (plus `contents: read`, and + `pull-requests: read` when the report runs with the default + `GITHUB_TOKEN`). +- Verification needs the GitHub CLI ≥ 2.49 (`gh attestation`). diff --git a/render/render_test.go b/render/render_test.go index 83fd300..1acfe59 100644 --- a/render/render_test.go +++ b/render/render_test.go @@ -190,6 +190,39 @@ func TestHTMLCadenceIsSubordinateContext(t *testing.T) { } } +func TestHTMLShowsVerifiedIdentity(t *testing.T) { + r := reportFixture() + r.Verification = &report.Verification{ + Status: report.StatusVerified, + Provider: "github-actions", + Repository: "acme/widgets", + WorkflowRef: "acme/widgets/.github/workflows/report.yml@refs/heads/main", + RunID: "9000000001", + RunURL: "https://github.com/acme/widgets/actions/runs/9000000001", + Attestation: &report.Attestation{ + Type: report.AttestationTypeSigstore, + URL: "https://github.com/acme/widgets/attestations", + VerifyCommand: "gh attestation verify report.json --repo acme/widgets", + }, + } + + out, err := render.HTML(r) + if err != nil { + t.Fatalf("HTML: %v", err) + } + html := string(out) + for _, want := range []string{ + "verified", + "acme/widgets/.github/workflows/report.yml@refs/heads/main", + "https://github.com/acme/widgets/actions/runs/9000000001", + "gh attestation verify report.json --repo acme/widgets", + } { + if !strings.Contains(html, want) { + t.Errorf("rendered HTML missing %q", want) + } + } +} + func TestHTMLOmitsAbsentSections(t *testing.T) { r := reportFixture() r.Collaboration = nil diff --git a/render/templates/sections/20-verification.tmpl b/render/templates/sections/20-verification.tmpl index 061c9d0..2a971c4 100644 --- a/render/templates/sections/20-verification.tmpl +++ b/render/templates/sections/20-verification.tmpl @@ -5,5 +5,18 @@ {{.Status}} {{if .Reason}}{{.Reason}}{{end}}

+ {{if .WorkflowRef}} +
+ {{if .Provider}}
Provider
{{.Provider}}
{{end}} + {{if .Repository}}
Repository
{{.Repository}}
{{end}} +
Workflow
{{.WorkflowRef}}
+ {{if .RunURL}}
Run
{{.RunURL}}
{{end}} +
+ {{end}} + {{with .Attestation}} +

Attestation ({{.Type}}): {{.URL}}

+

Verify the downloaded report.json with:

+
{{.VerifyCommand}}
+ {{end}} {{end}} diff --git a/report/report.go b/report/report.go index 1e08edf..b570f20 100644 --- a/report/report.go +++ b/report/report.go @@ -60,10 +60,17 @@ type Window struct { } // Verification is the mandatory verification block. Local runs carry an -// explicit StatusUnverified; CI attestation upgrades it. +// explicit StatusUnverified; CI attestation upgrades it and records the +// producing workflow identity plus a pointer to the attestation. type Verification struct { - Status string `json:"status"` - Reason string `json:"reason,omitempty"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Provider string `json:"provider,omitempty"` + Repository string `json:"repository,omitempty"` + WorkflowRef string `json:"workflow_ref,omitempty"` + RunID string `json:"run_id,omitempty"` + RunURL string `json:"run_url,omitempty"` + Attestation *Attestation `json:"attestation,omitempty"` } // Collaboration holds collaboration metrics. Each sub-struct is owned by diff --git a/report/verification.go b/report/verification.go new file mode 100644 index 0000000..20e53d0 --- /dev/null +++ b/report/verification.go @@ -0,0 +1,52 @@ +// Verification-block population: how a report run binds itself to a CI +// identity, and the honest unverified fallback everywhere else. +// +// Attestation only exists in CI. The CLI itself never claims more than the +// environment proves: outside a recognized CI environment the default +// unverified block from Build stands; inside GitHub Actions the block +// records the producing workflow identity and where its Sigstore +// attestation can be checked. +package report + +import "fmt" + +// AttestationTypeSigstore names the Sigstore/OIDC artifact attestation that +// GitHub's actions/attest-build-provenance produces over report.json. +const AttestationTypeSigstore = "sigstore-github-artifact-attestation" + +// Attestation points at where the report's attestation lives and how a +// consumer checks it. +type Attestation struct { + // Type names the attestation mechanism. + Type string `json:"type"` + // URL is the repository's attestations page on the platform. + URL string `json:"url"` + // VerifyCommand is the exact command a consumer runs against the + // downloaded report.json to verify origin. + VerifyCommand string `json:"verify_command"` +} + +// CIVerification inspects the environment (via getenv) and returns the +// verification block for a recognized CI run, or nil when not running in +// CI so the caller keeps the explicit unverified default. +func CIVerification(getenv func(string) string) *Verification { + if getenv("GITHUB_ACTIONS") != "true" { + return nil + } + repo := getenv("GITHUB_REPOSITORY") + runID := getenv("GITHUB_RUN_ID") + server := getenv("GITHUB_SERVER_URL") + return &Verification{ + Status: StatusVerified, + Provider: "github-actions", + Repository: repo, + WorkflowRef: getenv("GITHUB_WORKFLOW_REF"), + RunID: runID, + RunURL: fmt.Sprintf("%s/%s/actions/runs/%s", server, repo, runID), + Attestation: &Attestation{ + Type: AttestationTypeSigstore, + URL: fmt.Sprintf("%s/%s/attestations", server, repo), + VerifyCommand: fmt.Sprintf("gh attestation verify report.json --repo %s", repo), + }, + } +} diff --git a/report/verification_test.go b/report/verification_test.go new file mode 100644 index 0000000..b48ae6d --- /dev/null +++ b/report/verification_test.go @@ -0,0 +1,80 @@ +package report_test + +import ( + "testing" + "time" + + "github.com/grkanitz/coderepute/report" +) + +// envFrom returns a getenv func backed by a fixed map, so tests never +// depend on the real process environment. +func envFrom(vars map[string]string) func(string) string { + return func(key string) string { return vars[key] } +} + +// actionsEnv is a complete GitHub Actions identity environment, as the +// runner sets it for any job. +func actionsEnv() map[string]string { + return map[string]string{ + "GITHUB_ACTIONS": "true", + "GITHUB_REPOSITORY": "acme/widgets", + "GITHUB_WORKFLOW_REF": "acme/widgets/.github/workflows/report.yml@refs/heads/main", + "GITHUB_RUN_ID": "9000000001", + "GITHUB_SERVER_URL": "https://github.com", + } +} + +func TestCIVerificationInGitHubActions(t *testing.T) { + v := report.CIVerification(envFrom(actionsEnv())) + if v == nil { + t.Fatal("CIVerification in GitHub Actions = nil, want a populated block") + } + if v.Status != report.StatusVerified { + t.Errorf("Status = %q, want %q", v.Status, report.StatusVerified) + } + if v.Provider != "github-actions" { + t.Errorf("Provider = %q, want github-actions", v.Provider) + } + if v.Repository != "acme/widgets" { + t.Errorf("Repository = %q, want acme/widgets", v.Repository) + } + if want := "acme/widgets/.github/workflows/report.yml@refs/heads/main"; v.WorkflowRef != want { + t.Errorf("WorkflowRef = %q, want %q", v.WorkflowRef, want) + } + if v.RunID != "9000000001" { + t.Errorf("RunID = %q, want 9000000001", v.RunID) + } + if want := "https://github.com/acme/widgets/actions/runs/9000000001"; v.RunURL != want { + t.Errorf("RunURL = %q, want %q", v.RunURL, want) + } + if v.Attestation == nil { + t.Fatal("Attestation = nil, want a pointer to where the attestation can be checked") + } + if want := "gh attestation verify report.json --repo acme/widgets"; v.Attestation.VerifyCommand != want { + t.Errorf("Attestation.VerifyCommand = %q, want %q", v.Attestation.VerifyCommand, want) + } + if want := "https://github.com/acme/widgets/attestations"; v.Attestation.URL != want { + t.Errorf("Attestation.URL = %q, want %q", v.Attestation.URL, want) + } + if v.Attestation.Type == "" { + t.Error("Attestation.Type is empty, want a named attestation type") + } + + // The populated block must survive a build → marshal → parse round trip. + r := report.Build(activityFixture(), nil, nil, time.Now()) + r.Verification = v + if err := r.Validate(); err != nil { + t.Fatalf("Validate() with CI verification block: %v", err) + } +} + +func TestCIVerificationOutsideCIReturnsNil(t *testing.T) { + if v := report.CIVerification(envFrom(nil)); v != nil { + t.Errorf("CIVerification outside CI = %+v, want nil", v) + } + // GITHUB_ACTIONS must be exactly "true"; anything else is not CI. + if v := report.CIVerification(envFrom(map[string]string{"GITHUB_ACTIONS": "false"})); v != nil { + t.Errorf("CIVerification with GITHUB_ACTIONS=false = %+v, want nil", v) + } +}