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
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 18 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
name: release

on:
push:
tags:
- "v*"

permissions:
contents: write
id-token: write
Expand All @@ -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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
12 changes: 10 additions & 2 deletions renovate.json
Original file line number Diff line number Diff line change
@@ -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 (?<depName>github\\.com/securego/gosec/v2)/cmd/gosec@(?<currentValue>v\\S+)"],
"datasourceTemplate": "go"
}
]
}
166 changes: 166 additions & 0 deletions run_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading