diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e79dc19 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - run: go vet ./... + - run: go test -race -coverprofile=coverage.out ./... + - name: Enforce coverage >= 75% + run: | + total=$(go tool cover -func=coverage.out | awk '/^total:/ { sub("%", "", $3); print $3 }') + echo "coverage: ${total}%" + awk -v t="$total" 'BEGIN { exit (t < 75) }' + + gosec: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + # go run instead of the gosec action: its image may lag behind the Go version in go.mod. + - run: go run github.com/securego/gosec/v2/cmd/gosec@v2.30.0 ./... + + govulncheck: + runs-on: ubuntu-latest + steps: + - uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1.1.0 + with: + go-version-file: go.mod diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d63a842..9b4ed9e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,10 @@ name: release + on: push: tags: - "v*" + permissions: contents: write id-token: write @@ -12,8 +14,22 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: cli/gh-extension-precompile@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: cli/gh-extension-precompile@76961aa3bd1123d0a6fd42d0a41aca0696937c39 # v2.2.0 with: generate_attestations: true go_version_file: go.mod + - uses: sigstore/cosign-installer@756fe2d284c7292c4e9b62c8a28f1e5629c59532 # v4.1.3 + - name: Sign release assets (cosign keyless) + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + REPO: ${{ github.repository }} + run: | + gh release download "$TAG" --repo "$REPO" --dir dist + for f in dist/*; do + cosign sign-blob --yes --bundle "$f.sigstore.json" "$f" + done + gh release upload "$TAG" --repo "$REPO" dist/*.sigstore.json diff --git a/README.md b/README.md index 3d90dde..7be43b7 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,21 @@ If the repository uses a custom `sub` template, a warning is printed: the patter default template and will not match. GitHub Enterprise Server has no immutable subjects; use `--mutable` there. +## Releases + +Pushing a `v*` tag builds binaries for all platforms via +[gh-extension-precompile](https://github.com/cli/gh-extension-precompile), with GitHub build +provenance attestations and cosign keyless signatures (`*.sigstore.json`). Verify a download: + +```sh +gh attestation verify gh-sub-pattern_v1.0.0_darwin-arm64 --repo jabbrwcky/gh-sub-pattern + +cosign verify-blob gh-sub-pattern_v1.0.0_darwin-arm64 \ + --bundle gh-sub-pattern_v1.0.0_darwin-arm64.sigstore.json \ + --certificate-identity-regexp "^https://github.com/jabbrwcky/gh-sub-pattern/" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +``` + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md), [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) and diff --git a/main.go b/main.go index b03d36a..cc4ba98 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "errors" "fmt" + "io" "os" "os/exec" "strings" @@ -34,6 +35,12 @@ type repoInfo struct { } `json:"owner"` } +// Swapped in tests. +var ( + newClient = func(host string) (*api.RESTClient, error) { return api.NewRESTClient(api.ClientOptions{Host: host}) } + stdout io.Writer = os.Stdout +) + func main() { var cli CLI kong.Parse(&cli, @@ -50,7 +57,7 @@ func run(cli CLI) error { if err != nil { return err } - client, err := api.NewRESTClient(api.ClientOptions{Host: repo.Host}) + client, err := newClient(repo.Host) if err != nil { return err } @@ -82,7 +89,7 @@ func run(cli CLI) error { subs = []string{parseChoice(choice)} } for _, s := range subs { - fmt.Println("repo:" + repoSegment(info, immutable) + ":" + s) + fmt.Fprintln(stdout, "repo:"+repoSegment(info, immutable)+":"+s) } return nil } diff --git a/renovate.json b/renovate.json index 5db72dd..208d1e8 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,14 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:recommended" + "extends": ["config:recommended", "helpers:pinGitHubActionDigests"], + "postUpdateOptions": ["gomodTidy"], + "customManagers": [ + { + "customType": "regex", + "description": "Go tools invoked via go run in workflows", + "managerFilePatterns": ["/^\\.github/workflows/.+\\.ya?ml$/"], + "matchStrings": ["go run (?github\\.com/securego/gosec/v2)/cmd/gosec@(?v\\S+)"], + "datasourceTemplate": "go" + } ] } diff --git a/run_test.go b/run_test.go new file mode 100644 index 0000000..319ce0c --- /dev/null +++ b/run_test.go @@ -0,0 +1,166 @@ +package main + +import ( + "bytes" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cli/go-gh/v2/pkg/api" +) + +// routes maps API paths to JSON bodies; unknown paths return 404. +type routes map[string]string + +func (r routes) RoundTrip(req *http.Request) (*http.Response, error) { + body, ok := r[req.URL.Path] + status := http.StatusOK + if !ok { + status, body = http.StatusNotFound, `{"message":"Not Found"}` + } + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil +} + +func stubAPI(t *testing.T, r routes) *bytes.Buffer { + t.Helper() + oldClient, oldOut := newClient, stdout + t.Cleanup(func() { newClient, stdout = oldClient, oldOut }) + newClient = func(host string) (*api.RESTClient, error) { + return api.NewRESTClient(api.ClientOptions{Host: host, AuthToken: "test", Transport: r}) + } + var buf bytes.Buffer + stdout = &buf + return &buf +} + +// fakeBin puts an executable shell script named name first on PATH and returns its path. +func fakeBin(t *testing.T, name, script string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return path +} + +const ( + repoJSON = `{"id":456789,"name":"octo-repo","owner":{"id":123456,"login":"octo-org"}}` + subPath = "/repos/octo-org/octo-repo/actions/oidc/customization/sub" +) + +func TestRun(t *testing.T) { + for name, tc := range map[string]struct { + cli CLI + sub string // customization response, "" means 404 + want string + }{ + "immutable by default, canonical casing": { + CLI{Repo: "OCTO-ORG/OCTO-REPO", Ref: []string{"main"}}, "", + "repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main\n", + }, + "mutable flag": { + CLI{Ref: []string{"main"}, Mutable: true}, "", + "repo:octo-org/octo-repo:ref:refs/heads/main\n", + }, + "repo reports mutable": { + CLI{Tags: []string{"v"}}, `{"use_default":true,"use_immutable_subject":false}`, + "repo:octo-org/octo-repo:ref:refs/tags/v*\n", + }, + "repo reports immutable over flag": { + CLI{Environment: []string{"prod"}, Mutable: true}, `{"use_default":true,"use_immutable_subject":true}`, + "repo:octo-org@123456/octo-repo@456789:environment:prod\n", + }, + "field absent falls back to flag, custom template": { + CLI{Ref: []string{"pull_request"}, Tag: []string{"v1"}, Mutable: true}, `{"use_default":false,"include_claim_keys":["repo","context"]}`, + "repo:octo-org/octo-repo:pull_request\nrepo:octo-org/octo-repo:ref:refs/tags/v1\n", + }, + } { + t.Run(name, func(t *testing.T) { + r := routes{"/repos/octo-org/octo-repo": repoJSON, "/repos/OCTO-ORG/OCTO-REPO": repoJSON} + if tc.sub != "" { + r[subPath] = tc.sub + } + out := stubAPI(t, r) + if tc.cli.Repo == "" { + tc.cli.Repo = "octo-org/octo-repo" + } + if err := run(tc.cli); err != nil { + t.Fatal(err) + } + if out.String() != tc.want { + t.Errorf("got %q, want %q", out.String(), tc.want) + } + }) + } +} + +func TestRunErrors(t *testing.T) { + stubAPI(t, routes{"/repos/octo-org/octo-repo": repoJSON}) + for name, cli := range map[string]CLI{ + "invalid repo": {Repo: "a/b/c/d", Ref: []string{"main"}}, + "unknown repo": {Repo: "octo-org/missing", Ref: []string{"main"}}, + "no target, no terminal": {Repo: "octo-org/octo-repo"}, + } { + if err := run(cli); err == nil { + t.Errorf("%s: expected error", name) + } + } +} + +func TestResolveRepoCurrent(t *testing.T) { + t.Setenv("GH_REPO", "octo-org/octo-repo") + r, err := resolveRepo("") + if err != nil || r.Owner != "octo-org" || r.Name != "octo-repo" { + t.Fatalf("got %+v, %v", r, err) + } +} + +func TestFzf(t *testing.T) { + for name, tc := range map[string]struct { + script, want string + wantErr bool + }{ + "selection": {`printf 'ma\nref:refs/heads/main\n'`, "ref:refs/heads/main", false}, + "free text": {`printf 'release/2.x/*\n'; exit 1`, "release/2.x/*", false}, + "cancelled": {`exit 130`, "", true}, + } { + t.Run(name, func(t *testing.T) { + fakeBin(t, "fzf", "cat >/dev/null\n"+tc.script) + got, err := fzf([]string{"ref:refs/heads/main"}) + if (err != nil) != tc.wantErr || got != tc.want { + t.Errorf("got %q, %v", got, err) + } + }) + } +} + +func TestPick(t *testing.T) { + t.Setenv("GH_PATH", fakeBin(t, "gh", `case "$*" in +*environments*) exit 1 ;; +*branches*) printf 'main\nrelease/v1\n' ;; +*tags*) echo v1.0 ;; +esac`)) + cands := filepath.Join(t.TempDir(), "candidates") + fakeBin(t, "fzf", `cat > "`+cands+`"; printf '\nref:refs/heads/release/*\n'`) + + got, err := pick("github.com", "octo-org/octo-repo") + if err != nil || got != "ref:refs/heads/release/*" { + t.Fatalf("got %q, %v", got, err) + } + listed, _ := os.ReadFile(cands) + for _, want := range []string{"pull_request", "ref:refs/heads/release/*", "ref:refs/tags/v1.0"} { + if !strings.Contains(string(listed)+"\n", want+"\n") { + t.Errorf("fzf input missing %q:\n%s", want, listed) + } + } +}