diff --git a/github_readiness_test.go b/github_readiness_test.go new file mode 100644 index 0000000..6141e55 --- /dev/null +++ b/github_readiness_test.go @@ -0,0 +1,139 @@ +package readiness + +import ( + "os" + "strings" + "testing" +) + +const projectName = "mark2note" + +var releaseSuffixes = []string{"darwin-amd64", "darwin-arm64", "linux-amd64", "linux-arm64"} + +func TestGitHubCIWorkflowReadiness(t *testing.T) { + content := readTextFile(t, ".github/workflows/ci.yml") + for _, want := range []string{ + "name: CI", + "push:", + "pull_request:", + "actions/checkout@v6", + "fetch-depth: 0", + "actions/setup-go@v6", + "go-version-file: go.mod", + "scripts/secret-scan.sh", + "gofmt -l", + "go vet ./...", + "go test ./...", + } { + assertContains(t, content, want) + } +} + +func TestGitHubReleaseWorkflowReadiness(t *testing.T) { + content := readTextFile(t, ".github/workflows/release.yml") + for _, want := range []string{ + "name: Release", + "tags:", + "v*", + "contents: write", + "preflight:", + "build:", + "release:", + "needs: preflight", + "needs: build", + "actions/checkout@v6", + "fetch-depth: 0", + "actions/setup-go@v6", + "go-version-file: go.mod", + "scripts/ci-local.sh clean", + "package=\"" + projectName + "-${SUFFIX}\"", + "actions/upload-artifact@v7", + "actions/download-artifact@v8", + "sha256sum", + "checksums.txt", + "gh release view", + "gh release upload", + "gh release create", + "GH_TOKEN", + } { + assertContains(t, content, want) + } + for _, suffix := range releaseSuffixes { + assertContains(t, content, suffix) + } + for _, forbidden := range []string{ + "secrets.", + "softprops/action-gh-release", + "GoReleaser", + "goreleaser", + "git push", + "git tag", + } { + assertNotContains(t, content, forbidden) + } +} + +func TestGitHubCodeQLWorkflowReadiness(t *testing.T) { + content := readTextFile(t, ".github/workflows/codeql.yml") + for _, want := range []string{ + "name: CodeQL", + "push:", + "pull_request:", + "schedule:", + "security-events: write", + "actions/checkout@v6", + "github/codeql-action/init@v4", + "languages: go", + "github/codeql-action/autobuild@v4", + "github/codeql-action/analyze@v4", + } { + assertContains(t, content, want) + } +} + +func TestGitHubReadinessScriptUsesDedicatedEndpoints(t *testing.T) { + path := "scripts/github-readiness.sh" + content := readTextFile(t, path) + info, err := os.Stat(path) + if err != nil { + t.Fatalf("expected %s to exist: %v", path, err) + } + if info.Mode().Perm()&0o111 == 0 { + t.Fatalf("expected %s to be executable", path) + } + for _, want := range []string{ + "gh api \"repos/${repo}\"", + "security_and_analysis.secret_scanning.status", + "security_and_analysis.secret_scanning_push_protection.status", + "gh api \"repos/${repo}/private-vulnerability-reporting\"", + "private-vulnerability-reporting", + "branches/${default_branch}/protection", + "code-scanning/analyses", + } { + assertContains(t, content, want) + } + assertNotContains(t, content, "security_and_analysis.private") +} + +func readTextFile(t *testing.T, path string) string { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected %s to exist: %v", path, err) + } + return string(content) +} + +func assertContains(t *testing.T, content, want string) { + t.Helper() + if !strings.Contains(content, want) { + t.Fatalf("expected content to contain %q", want) + } +} + +func assertNotContains(t *testing.T, content, forbidden string) { + t.Helper() + if strings.Contains(content, forbidden) { + t.Fatalf("expected content not to contain %q", forbidden) + } +} diff --git a/scripts/github-readiness.sh b/scripts/github-readiness.sh new file mode 100755 index 0000000..5157bd2 --- /dev/null +++ b/scripts/github-readiness.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + printf 'usage: %s [owner/repo]\n' "$0" >&2 +} + +if [[ $# -gt 1 || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 2 +fi + +if ! command -v gh >/dev/null 2>&1; then + printf 'missing required command: gh\n' >&2 + exit 127 +fi + +repo="${1:-}" +if [[ -z "$repo" ]]; then + repo="$(gh repo view --json nameWithOwner -q .nameWithOwner)" +fi + +if [[ "$repo" != */* ]]; then + printf 'repository must use owner/repo format: %s\n' "$repo" >&2 + exit 2 +fi + +failed=0 + +status_line() { + local label="$1" + local value="$2" + printf '%-38s %s\n' "$label:" "$value" +} + +require_enabled() { + local label="$1" + local value="$2" + if [[ "$value" == "enabled" || "$value" == "true" ]]; then + status_line "$label" "$value" + return + fi + status_line "$label" "$value" + failed=1 +} + +default_branch="$(gh api "repos/${repo}" --jq '.default_branch')" +secret_scanning="$(gh api "repos/${repo}" --jq '.security_and_analysis.secret_scanning.status // "unavailable"')" +push_protection="$(gh api "repos/${repo}" --jq '.security_and_analysis.secret_scanning_push_protection.status // "unavailable"')" +private_vulnerability_reporting="$(gh api "repos/${repo}/private-vulnerability-reporting" --jq '.enabled')" + +if required_checks="$(gh api "repos/${repo}/branches/${default_branch}/protection" --jq '.required_status_checks.contexts // [] | join(", ")' 2>/dev/null)"; then + branch_protection="enabled" +else + branch_protection="unavailable or disabled" + required_checks="" +fi + +code_scanning_tools="$(gh api "repos/${repo}/code-scanning/analyses" --jq '.[].tool.name' 2>/dev/null | sort -u || true)" +if [[ -n "$code_scanning_tools" ]]; then + code_scanning="enabled" +else + code_scanning="unavailable or no analyses" + failed=1 +fi + +status_line "Repository" "$repo" +status_line "Default branch" "$default_branch" +require_enabled "Secret scanning" "$secret_scanning" +require_enabled "Push protection" "$push_protection" +require_enabled "Private vulnerability reporting" "$private_vulnerability_reporting" +require_enabled "Branch protection" "$branch_protection" +if [[ -n "$required_checks" ]]; then + status_line "Required status checks" "$required_checks" +else + status_line "Required status checks" "none reported" + failed=1 +fi +status_line "Code scanning" "$code_scanning" +if [[ -n "$code_scanning_tools" ]]; then + status_line "Code scanning tools" "$code_scanning_tools" +fi + +exit "$failed"