mirror of
https://github.com/kunchenguid/no-mistakes.git
synced 2026-09-19 07:53:54 +08:00
53ceb81ea6
* fix(release): refresh channels.json after every release-please cut GitHub does not cascade GITHUB_TOKEN release events, so the standalone publish-channels trigger never ran for release-please. Call that workflow from release.yml after finalize so the update manifest publishes with the binaries and a failure fails the release run. * no-mistakes(review): Evaluate workflow conditions semantically and tighten checkout * no-mistakes(review): Model reusable workflow caller event accurately
279 lines
9.2 KiB
Go
279 lines
9.2 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestReleaseWorkflowUsesScopedConcurrencyGroup(t *testing.T) {
|
|
data, err := os.ReadFile(".github/workflows/release.yml")
|
|
if err != nil {
|
|
t.Fatalf("read workflow: %v", err)
|
|
}
|
|
|
|
content := string(data)
|
|
if !strings.Contains(content, "group: release-${{ github.ref }}") {
|
|
t.Fatalf("release workflow must scope concurrency by ref")
|
|
}
|
|
if strings.Contains(content, "group: release\n") {
|
|
t.Fatalf("release workflow must not use a global concurrency group")
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowDoesNotDefineValidationJobs(t *testing.T) {
|
|
data, err := os.ReadFile(".github/workflows/release.yml")
|
|
if err != nil {
|
|
t.Fatalf("read workflow: %v", err)
|
|
}
|
|
|
|
content := string(data)
|
|
for _, job := range []string{"check", "test"} {
|
|
if strings.Contains(content, "\n "+job+":\n") {
|
|
t.Fatalf("release workflow must not define %q; CI owns validation now", job)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowRunsReleasePleaseWithoutValidationGuards(t *testing.T) {
|
|
data, err := os.ReadFile(".github/workflows/release.yml")
|
|
if err != nil {
|
|
t.Fatalf("read workflow: %v", err)
|
|
}
|
|
|
|
block := extractJobBlock(t, string(data), "release-please")
|
|
if strings.Contains(block, "needs:") {
|
|
t.Fatalf("release-please must not depend on in-workflow validation jobs")
|
|
}
|
|
guard := "!startsWith(github.event.head_commit.message, 'chore(main): release')"
|
|
if strings.Contains(block, guard) {
|
|
t.Fatalf("release-please must not carry the old release-commit skip guard")
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowBuildStartsOnlyWhenReleaseIsCreated(t *testing.T) {
|
|
data, err := os.ReadFile(".github/workflows/release.yml")
|
|
if err != nil {
|
|
t.Fatalf("read workflow: %v", err)
|
|
}
|
|
|
|
block := extractJobBlock(t, string(data), "build-and-upload")
|
|
if !strings.Contains(block, "if: needs.release-please.outputs.release_created == 'true'") {
|
|
t.Fatalf("build-and-upload must run only when release-please created a release")
|
|
}
|
|
for _, unexpected := range []string{"!cancelled()", "needs.release-please.result == 'success'"} {
|
|
if strings.Contains(block, unexpected) {
|
|
t.Fatalf("build-and-upload must not keep the old skipped-validation guard %q", unexpected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowEmbedsSelfHostedTelemetryConfig(t *testing.T) {
|
|
data, err := os.ReadFile(".github/workflows/release.yml")
|
|
if err != nil {
|
|
t.Fatalf("read workflow: %v", err)
|
|
}
|
|
|
|
block := extractJobBlock(t, string(data), "build-and-upload")
|
|
for _, want := range []string{
|
|
"UMAMI_HOST: https://a.kunchenguid.com",
|
|
"UMAMI_WEBSITE_ID: f959e889-92f5-4121-8a1f-571b10861198",
|
|
"TelemetryHost=${UMAMI_HOST}",
|
|
"TelemetryWebsiteID=${UMAMI_WEBSITE_ID}",
|
|
} {
|
|
if !strings.Contains(block, want) {
|
|
t.Fatalf("build-and-upload must contain %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Partial-release protection: release-please must create drafts so that a
|
|
// release is never marked "latest" until all binaries and checksums are
|
|
// uploaded. A separate finalize job gates the promotion on every asset job
|
|
// succeeding.
|
|
func TestReleasePleaseConfigCreatesDrafts(t *testing.T) {
|
|
data, err := os.ReadFile("release-please-config.json")
|
|
if err != nil {
|
|
t.Fatalf("read config: %v", err)
|
|
}
|
|
var cfg struct {
|
|
Packages map[string]struct {
|
|
Draft bool `json:"draft"`
|
|
} `json:"packages"`
|
|
}
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
t.Fatalf("parse config: %v", err)
|
|
}
|
|
pkg, ok := cfg.Packages["."]
|
|
if !ok {
|
|
t.Fatalf("release-please config missing '.' package")
|
|
}
|
|
if !pkg.Draft {
|
|
t.Fatalf("release-please must create releases as drafts; partial releases would otherwise be marked latest before binaries are uploaded")
|
|
}
|
|
}
|
|
|
|
func TestReleasePleaseConfigForcesTagCreation(t *testing.T) {
|
|
data, err := os.ReadFile("release-please-config.json")
|
|
if err != nil {
|
|
t.Fatalf("read config: %v", err)
|
|
}
|
|
var cfg struct {
|
|
Packages map[string]struct {
|
|
ForceTagCreation bool `json:"force-tag-creation"`
|
|
} `json:"packages"`
|
|
}
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
t.Fatalf("parse config: %v", err)
|
|
}
|
|
pkg, ok := cfg.Packages["."]
|
|
if !ok {
|
|
t.Fatalf("release-please config missing '.' package")
|
|
}
|
|
if !pkg.ForceTagCreation {
|
|
t.Fatalf("release-please config must force tag creation so an existing GitHub release cannot silently prevent the tag from being recreated")
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowDoesNotOverrideReleaseType(t *testing.T) {
|
|
data, err := os.ReadFile(".github/workflows/release.yml")
|
|
if err != nil {
|
|
t.Fatalf("read workflow: %v", err)
|
|
}
|
|
content := string(data)
|
|
|
|
block := extractJobBlock(t, content, "release-please")
|
|
if strings.Contains(block, "release-type:") {
|
|
t.Fatalf("release workflow must not override release-type; release-please should read it from release-please-config.json")
|
|
}
|
|
if !strings.Contains(block, "config-file: release-please-config.json") {
|
|
t.Fatalf("release workflow must point release-please at release-please-config.json")
|
|
}
|
|
}
|
|
|
|
func TestReleaseWorkflowPublishesPrereleaseOnlyAfterAssetsComplete(t *testing.T) {
|
|
data, err := os.ReadFile(".github/workflows/release.yml")
|
|
if err != nil {
|
|
t.Fatalf("read workflow: %v", err)
|
|
}
|
|
content := string(data)
|
|
|
|
block := extractJobBlock(t, content, "finalize")
|
|
|
|
required := []string{
|
|
"!cancelled()",
|
|
"needs.release-please.result == 'success'",
|
|
"needs.build-and-upload.result == 'success'",
|
|
"needs.checksums.result == 'success'",
|
|
"needs.release-please.outputs.release_created == 'true'",
|
|
"gh release edit",
|
|
"--draft=false",
|
|
"--prerelease=true",
|
|
}
|
|
for _, req := range required {
|
|
if !strings.Contains(block, req) {
|
|
t.Fatalf("finalize job must contain %q so a draft is only published as prerelease after every asset job succeeds", req)
|
|
}
|
|
}
|
|
if strings.Contains(block, "--latest=true") {
|
|
t.Fatalf("finalize job must not auto-promote to latest; latest is set manually")
|
|
}
|
|
|
|
for _, dep := range []string{"release-please", "build-and-upload", "checksums"} {
|
|
if !strings.Contains(block, "- "+dep) {
|
|
t.Fatalf("finalize job must declare %q in needs so its gate sees all upstream results", dep)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestReleaseWorkflowCallsPublishChannelsAfterFinalize pins that a
|
|
// release-please cut refreshes channels.json from the same run, after the
|
|
// prerelease and every binary asset are live. GitHub does not cascade
|
|
// GITHUB_TOKEN release events, so on: release cannot cover this path.
|
|
func TestReleaseWorkflowCallsPublishChannelsAfterFinalize(t *testing.T) {
|
|
wf := loadReleaseWorkflowDoc(t)
|
|
job := wf.Jobs["publish-channels"]
|
|
if job == nil {
|
|
t.Fatal("release workflow must call publish-channels as a job so a GITHUB_TOKEN-published release still refreshes the channel manifest")
|
|
}
|
|
if job.Uses != "./.github/workflows/publish-channels.yml" {
|
|
t.Fatalf("publish-channels uses = %q, want the reusable workflow so a failure fails this release run", job.Uses)
|
|
}
|
|
if len(job.Steps) != 0 {
|
|
t.Fatalf("publish-channels must be a reusable-workflow job with no steps, got %d (a gh workflow run fire-and-forget would not fail this run)", len(job.Steps))
|
|
}
|
|
if job.Permissions.Contents != "write" {
|
|
t.Fatalf("publish-channels contents permission = %q, want write to upload the channels asset", job.Permissions.Contents)
|
|
}
|
|
|
|
needed := map[string]bool{}
|
|
for _, dep := range job.needs() {
|
|
needed[dep] = true
|
|
}
|
|
if !needed["finalize"] {
|
|
t.Fatalf("publish-channels needs = %v, want finalize so channels.json is not pointed at a still-draft release or a release missing binaries", job.needs())
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
context workflowConditionContext
|
|
wantRun bool
|
|
}{
|
|
{name: "finalize succeeded", context: workflowConditionContext{Needs: map[string]string{"finalize": "success"}}, wantRun: true},
|
|
{name: "finalize failed", context: workflowConditionContext{Needs: map[string]string{"finalize": "failure"}}, wantRun: false},
|
|
{name: "finalize skipped", context: workflowConditionContext{Needs: map[string]string{"finalize": "skipped"}}, wantRun: false},
|
|
{name: "run cancelled", context: workflowConditionContext{Cancelled: true, Needs: map[string]string{"finalize": "success"}}, wantRun: false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := evaluateWorkflowCondition(job.If, tc.context)
|
|
if err != nil {
|
|
t.Fatalf("evaluate publish-channels condition: %v", err)
|
|
}
|
|
if got != tc.wantRun {
|
|
t.Fatalf("publish-channels runs = %t, want %t", got, tc.wantRun)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractJobBlockHandlesCRLF(t *testing.T) {
|
|
lf := "jobs:\n foo:\n runs-on: ubuntu-latest\n steps:\n - run: echo foo\n bar:\n runs-on: ubuntu-latest\n"
|
|
crlf := strings.ReplaceAll(lf, "\n", "\r\n")
|
|
|
|
block := extractJobBlock(t, crlf, "foo")
|
|
if !strings.Contains(block, "echo foo") {
|
|
t.Fatalf("CRLF block missing foo body: %q", block)
|
|
}
|
|
if strings.Contains(block, "bar:") {
|
|
t.Fatalf("CRLF block must stop before next job: %q", block)
|
|
}
|
|
}
|
|
|
|
func extractJobBlock(t *testing.T, content, name string) string {
|
|
t.Helper()
|
|
content = strings.ReplaceAll(content, "\r\n", "\n")
|
|
header := "\n " + name + ":\n"
|
|
start := strings.Index(content, header)
|
|
if start < 0 {
|
|
t.Fatalf("could not locate %s job in workflow", name)
|
|
}
|
|
rest := content[start+len(header):]
|
|
idx := 0
|
|
for {
|
|
next := strings.Index(rest[idx:], "\n ")
|
|
if next < 0 {
|
|
return rest
|
|
}
|
|
pos := idx + next + 1
|
|
if pos+2 >= len(rest) {
|
|
return rest
|
|
}
|
|
ch := rest[pos+2]
|
|
if ch != ' ' && ch != '#' && ch != '\n' {
|
|
return rest[:pos]
|
|
}
|
|
idx = pos + 1
|
|
}
|
|
}
|