From 3ea685096021008e230e8df5815feb9c8b0b9669 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 6 Jun 2026 14:39:07 -0400 Subject: [PATCH] release: prepare plugin manifest from tag --- .github/workflows/ci.yml | 5 +- .github/workflows/release.yml | 27 ++-- cmd/release-prep/main.go | 22 ++++ go.mod | 1 + internal/releaseprep/manifest.go | 175 ++++++++++++++++++++++++++ internal/releaseprep/manifest_test.go | 111 ++++++++++++++++ release_workflow_test.go | 32 +++++ 7 files changed, 362 insertions(+), 11 deletions(-) create mode 100644 cmd/release-prep/main.go create mode 100644 internal/releaseprep/manifest.go create mode 100644 internal/releaseprep/manifest_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb5d4fd..3e5c492 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,8 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod - name: Configure private Go modules @@ -23,5 +23,6 @@ jobs: git config --global url."https://x-access-token:${RELEASES_TOKEN}@github.com/GoCodeAlone/".insteadOf "https://github.com/GoCodeAlone/" go env -w GOPRIVATE=github.com/GoCodeAlone/* go env -w GONOSUMDB=github.com/GoCodeAlone/* + - run: go run ./cmd/release-prep - run: go test ./... -race -count=1 - run: go vet ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13c75f5..463693e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,9 +10,21 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 12 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + - name: Configure private Go modules + env: + RELEASES_TOKEN: ${{ secrets.RELEASES_TOKEN || github.token }} + run: | + git config --global url."https://x-access-token:${RELEASES_TOKEN}@github.com/GoCodeAlone/".insteadOf "https://github.com/GoCodeAlone/" + go env -w GOPRIVATE=github.com/GoCodeAlone/* + go env -w GONOSUMDB=github.com/GoCodeAlone/* + - name: Prepare release manifest + run: go run ./cmd/release-prep --tag "${{ github.ref_name }}" --write - name: Install wfctl v0.74.5 run: | mkdir -p "${RUNNER_TEMP}/wfctl-bin" @@ -22,10 +34,7 @@ jobs: chmod +x "${RUNNER_TEMP}/wfctl-bin/wfctl" - name: Validate plugin contract for publish (pre-build) run: "${{ runner.temp }}/wfctl-bin/wfctl plugin validate-contract --for-publish --tag ${{ github.ref_name }} ." - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - uses: goreleaser/goreleaser-action@v7 + - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: distribution: goreleaser version: '~> v2' @@ -56,16 +65,16 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 25 steps: - - uses: actions/checkout@v6 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push product capture browser image id: build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . file: docker/product-capture-browser/Dockerfile diff --git a/cmd/release-prep/main.go b/cmd/release-prep/main.go new file mode 100644 index 0000000..9370344 --- /dev/null +++ b/cmd/release-prep/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/GoCodeAlone/workflow-plugin-product-capture/internal/releaseprep" +) + +func main() { + var opts releaseprep.Options + flag.StringVar(&opts.ManifestPath, "manifest", "plugin.json", "plugin manifest path") + flag.StringVar(&opts.Tag, "tag", "", "release tag, defaults to v") + flag.BoolVar(&opts.Write, "write", false, "rewrite plugin.json release metadata instead of checking it") + flag.Parse() + + if err := releaseprep.Run(opts); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index 94741d9..61880e8 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.0 require ( github.com/GoCodeAlone/workflow v0.74.5 github.com/GoCodeAlone/workflow-plugin-compute-core v0.4.0 + golang.org/x/mod v0.36.0 golang.org/x/net v0.54.0 ) diff --git a/internal/releaseprep/manifest.go b/internal/releaseprep/manifest.go new file mode 100644 index 0000000..c924dff --- /dev/null +++ b/internal/releaseprep/manifest.go @@ -0,0 +1,175 @@ +package releaseprep + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "golang.org/x/mod/semver" +) + +const releaseURLPrefix = "https://github.com/GoCodeAlone/workflow-plugin-product-capture/releases/download/" + +type Manifest struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + Author string `json:"author,omitempty"` + License string `json:"license,omitempty"` + Type string `json:"type,omitempty"` + Tier string `json:"tier,omitempty"` + Private bool `json:"private"` + MinEngineVersion string `json:"minEngineVersion,omitempty"` + Keywords []string `json:"keywords,omitempty"` + Homepage string `json:"homepage,omitempty"` + Repository string `json:"repository,omitempty"` + Capabilities json.RawMessage `json:"capabilities,omitempty"` + Contracts []json.RawMessage `json:"contracts,omitempty"` + Downloads []Download `json:"downloads,omitempty"` + raw map[string]json.RawMessage +} + +type Download struct { + OS string `json:"os"` + Arch string `json:"arch"` + URL string `json:"url"` +} + +type Options struct { + ManifestPath string + Tag string + Write bool +} + +func Run(opts Options) error { + if opts.ManifestPath == "" { + opts.ManifestPath = "plugin.json" + } + manifest, err := Read(opts.ManifestPath) + if err != nil { + return err + } + tag := opts.Tag + if tag == "" { + tag = "v" + manifest.Version + } + updated, err := Prepare(manifest, tag) + if err != nil { + return err + } + if !opts.Write { + return Check(manifest, updated) + } + return Write(opts.ManifestPath, updated) +} + +func Read(path string) (Manifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return Manifest{}, err + } + var manifest Manifest + if err := json.NewDecoder(bytes.NewReader(data)).Decode(&manifest); err != nil { + return Manifest{}, fmt.Errorf("decode %s: %w", path, err) + } + if err := json.Unmarshal(data, &manifest.raw); err != nil { + return Manifest{}, fmt.Errorf("decode raw %s: %w", path, err) + } + return manifest, nil +} + +func Prepare(manifest Manifest, tag string) (Manifest, error) { + version, err := versionFromTag(tag) + if err != nil { + return Manifest{}, err + } + if manifest.Name == "" { + return Manifest{}, errors.New("plugin.json.name is required") + } + if len(manifest.Downloads) == 0 { + return Manifest{}, errors.New("plugin.json.downloads is required") + } + manifest.Version = version + manifest.Downloads = append([]Download(nil), manifest.Downloads...) + for i := range manifest.Downloads { + dl := &manifest.Downloads[i] + if dl.OS == "" || dl.Arch == "" { + return Manifest{}, fmt.Errorf("downloads[%d] must declare os and arch", i) + } + dl.URL = fmt.Sprintf("%s%s/%s-%s-%s.tar.gz", releaseURLPrefix, tag, manifest.Name, dl.OS, dl.Arch) + } + return manifest, nil +} + +func Check(current, expected Manifest) error { + var problems []string + if current.Version != expected.Version { + problems = append(problems, fmt.Sprintf("plugin.json.version=%q, want %q", current.Version, expected.Version)) + } + if len(current.Downloads) != len(expected.Downloads) { + problems = append(problems, fmt.Sprintf("plugin.json.downloads has %d entries, want %d", len(current.Downloads), len(expected.Downloads))) + } + for i := range current.Downloads { + if i >= len(expected.Downloads) { + break + } + if current.Downloads[i] != expected.Downloads[i] { + problems = append(problems, fmt.Sprintf("plugin.json.downloads[%d]=%+v, want %+v", i, current.Downloads[i], expected.Downloads[i])) + } + } + if len(problems) > 0 { + return fmt.Errorf("release metadata is stale:\n- %s", strings.Join(problems, "\n- ")) + } + return nil +} + +func Write(path string, manifest Manifest) error { + fields, err := manifest.rawFields() + if err != nil { + return err + } + data, err := json.MarshalIndent(fields, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o644) +} + +func (manifest Manifest) rawFields() (map[string]json.RawMessage, error) { + fields := make(map[string]json.RawMessage, len(manifest.raw)+2) + if len(manifest.raw) > 0 { + for key, value := range manifest.raw { + fields[key] = value + } + } else { + data, err := json.Marshal(manifest) + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, &fields); err != nil { + return nil, err + } + } + version, err := json.Marshal(manifest.Version) + if err != nil { + return nil, err + } + downloads, err := json.Marshal(manifest.Downloads) + if err != nil { + return nil, err + } + fields["version"] = version + fields["downloads"] = downloads + return fields, nil +} + +func versionFromTag(tag string) (string, error) { + if !semver.IsValid(tag) || semver.Prerelease(tag) != "" || semver.Build(tag) != "" { + return "", fmt.Errorf("tag %q must be release semver vN.N.N", tag) + } + return strings.TrimPrefix(tag, "v"), nil +} diff --git a/internal/releaseprep/manifest_test.go b/internal/releaseprep/manifest_test.go new file mode 100644 index 0000000..d74c3d8 --- /dev/null +++ b/internal/releaseprep/manifest_test.go @@ -0,0 +1,111 @@ +package releaseprep + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPrepareUpdatesVersionAndDownloadURLs(t *testing.T) { + manifest := sampleManifest() + + got, err := Prepare(manifest, "v0.2.0") + if err != nil { + t.Fatal(err) + } + if got.Version != "0.2.0" { + t.Fatalf("version = %q, want 0.2.0", got.Version) + } + wantURL := "https://github.com/GoCodeAlone/workflow-plugin-product-capture/releases/download/v0.2.0/workflow-plugin-product-capture-linux-amd64.tar.gz" + if got.Downloads[0].URL != wantURL { + t.Fatalf("download url = %q, want %q", got.Downloads[0].URL, wantURL) + } +} + +func TestCheckRejectsStaleManifest(t *testing.T) { + current := sampleManifest() + expected, err := Prepare(current, "v0.2.0") + if err != nil { + t.Fatal(err) + } + + err = Check(current, expected) + if err == nil { + t.Fatal("expected stale manifest error") + } + if !strings.Contains(err.Error(), "plugin.json.version") || + !strings.Contains(err.Error(), "plugin.json.downloads[0]") { + t.Fatalf("error did not include stale fields: %v", err) + } +} + +func TestRunWritesPreparedManifest(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "plugin.json") + if err := os.WriteFile(path, []byte(`{ + "name": "workflow-plugin-product-capture", + "version": "0.1.11", + "futureField": {"kept": true}, + "downloads": [ + { + "os": "linux", + "arch": "amd64", + "url": "https://github.com/GoCodeAlone/workflow-plugin-product-capture/releases/download/v0.1.11/workflow-plugin-product-capture-linux-amd64.tar.gz" + } + ] +}`), 0o644); err != nil { + t.Fatal(err) + } + + if err := Run(Options{ManifestPath: path, Tag: "v0.2.0", Write: true}); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `"version": "0.2.0"`, + `/releases/download/v0.2.0/`, + } { + if !strings.Contains(string(got), want) { + t.Fatalf("written manifest missing %q:\n%s", want, got) + } + } + if !strings.Contains(string(got), `"futureField":`) { + t.Fatalf("written manifest did not preserve unknown field:\n%s", got) + } +} + +func TestPrepareRejectsNonReleaseTag(t *testing.T) { + for _, tag := range []string{"0.2.0", "v0.2.0-rc.1", "v0.2.0+build"} { + if _, err := Prepare(sampleManifest(), tag); err == nil { + t.Fatalf("Prepare(%q) succeeded, want error", tag) + } + } +} + +func sampleManifest() Manifest { + return Manifest{ + Name: "workflow-plugin-product-capture", + Version: "0.1.11", + Description: "Product URL capture provider for workflow-compute", + Author: "GoCodeAlone", + License: "MIT", + Type: "external", + Tier: "community", + MinEngineVersion: "0.57.4", + Keywords: []string{"product-capture"}, + Homepage: "https://github.com/GoCodeAlone/workflow-plugin-product-capture", + Repository: "https://github.com/GoCodeAlone/workflow-plugin-product-capture", + Capabilities: []byte(`{"stepTypes":["step.product_capture"]}`), + Downloads: []Download{ + { + OS: "linux", + Arch: "amd64", + URL: "https://github.com/GoCodeAlone/workflow-plugin-product-capture/releases/download/v0.1.11/workflow-plugin-product-capture-linux-amd64.tar.gz", + }, + }, + } +} diff --git a/release_workflow_test.go b/release_workflow_test.go index f101918..cc7c27d 100644 --- a/release_workflow_test.go +++ b/release_workflow_test.go @@ -2,10 +2,13 @@ package productcapture_test import ( "os" + "regexp" "strings" "testing" ) +var pinnedActionRef = regexp.MustCompile(`^(-\s*)?uses:\s+\S+@[0-9a-f]{40}(\s+#\s+\S+)?$`) + func TestReleaseWorkflowUsesGlobalDispatchToken(t *testing.T) { data, err := os.ReadFile(".github/workflows/release.yml") if err != nil { @@ -26,6 +29,22 @@ func TestReleaseWorkflowUsesGlobalDispatchToken(t *testing.T) { if !strings.Contains(workflow, "needs: [release, runtime-image]") { t.Fatal("registry notification must wait for the runtime image publish") } + if !strings.Contains(workflow, "go run ./cmd/release-prep --tag \"${{ github.ref_name }}\" --write") { + t.Fatal("release workflow must rewrite plugin.json metadata from the release tag before validation") + } + assertWorkflowUsesPinnedActions(t, ".github/workflows/release.yml", workflow) +} + +func TestCIWorkflowChecksReleaseManifest(t *testing.T) { + data, err := os.ReadFile(".github/workflows/ci.yml") + if err != nil { + t.Fatal(err) + } + workflow := string(data) + if !strings.Contains(workflow, "go run ./cmd/release-prep") { + t.Fatal("CI workflow must check plugin.json release metadata consistency") + } + assertWorkflowUsesPinnedActions(t, ".github/workflows/ci.yml", workflow) } func TestRuntimeImageInstallsChromeAndPlaywrightWithoutBundledBrowser(t *testing.T) { @@ -44,3 +63,16 @@ func TestRuntimeImageInstallsChromeAndPlaywrightWithoutBundledBrowser(t *testing } } } + +func assertWorkflowUsesPinnedActions(t *testing.T, path, workflow string) { + t.Helper() + for _, line := range strings.Split(workflow, "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "- uses:") && !strings.HasPrefix(trimmed, "uses:") { + continue + } + if !pinnedActionRef.MatchString(trimmed) { + t.Fatalf("%s action reference must be pinned to a commit SHA: %s", path, trimmed) + } + } +}