Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/workflows/coderepute-report.yml
Original file line number Diff line number Diff line change
@@ -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 <org/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 }}
57 changes: 57 additions & 0 deletions .github/workflows/demo-report.yml
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
108 changes: 108 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions cmd/coderepute/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions cmd/coderepute/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading