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)
+ }
+}