feat: migrate pmg to nx based release automation (#293)

* feat: Migrate release system to Nx with platform-specific npm packages

* add go.work.sum

* fix: CI deprecations, stale action pins, and signal propagation

* fix: update e2e workflows to pnpm 11 and latest action SHAs

* fix: update pmg-e2e.yml to Node 24 with Go and pnpm caching

* fix: resolve E2E failures, remove goreleaser-test, update action SHAs

* fix: restore goreleaser-test (required check)

* fix: escape pnpm workspace detection for yarn/pnpx tests, update action versions
This commit is contained in:
Arunanshu Biswas
2026-05-28 17:22:11 +05:30
committed by GitHub
parent 19b9f2ca1f
commit 1c25395d74
40 changed files with 3205 additions and 698 deletions
+4
View File
@@ -0,0 +1,4 @@
# sync-binaries
Copies GoReleaser build artifacts into the npm platform packages under `packages/`.
Run via Nx: `pnpm nx run sync-binaries:run` (snapshot) or `pnpm nx run sync-binaries:run-release` (release).
+169
View File
@@ -0,0 +1,169 @@
// Sync binaries to packages directory from goreleaser's dist/ directory.
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"log"
"os"
"path/filepath"
"github.com/go-playground/validator/v10"
)
type GoreleaserArtifact struct {
Path string `json:"path" validate:"required"`
Goos string `json:"goos" validate:"required"`
Goarch string `json:"goarch"`
Type string `json:"type" validate:"required"`
}
var goArchToNodeArchMap = map[string]string{
"amd64": "x64",
"386": "x86",
"arm64": "arm64",
}
var goOsToNodeOsMap = map[string]string{
"windows": "win32",
}
func main() {
artifactsPath := flag.String("artifacts-path", "dist/artifacts.json", "Path to goreleaser artifacts.json")
packagesPath := flag.String("packages-path", "./packages", "Path to the npm packages directory")
strict := flag.Bool("strict", true, "Fail if a package directory does not exist for a built artifact")
setVersion := flag.String("set-version", "", "Semver x.y.z to write into all non-private package.json files under packages-path")
verifyBins := flag.Bool("verify-bins", false, "Verify that each platform package has a non-empty bin/ directory after sync")
flag.Parse()
artifactsBytes, err := os.ReadFile(*artifactsPath)
if err != nil {
log.Fatalf("failed to read artifacts.json (did you run goreleaser build?): %v", err)
}
var artifacts []GoreleaserArtifact
if err := json.Unmarshal(artifactsBytes, &artifacts); err != nil {
log.Fatalf("failed to parse artifacts.json: %v", err)
}
validate := validator.New(validator.WithRequiredStructEnabled())
for _, artifact := range artifacts {
switch artifact.Type {
case "Binary":
if err := validate.Struct(artifact); err != nil {
log.Printf("skipping invalid artifact: %v", err)
continue
}
// goreleaser v2 emits a universal macOS binary as type "Binary"
// with goarch "all" when universal_binaries.replace is true.
// Copy it to both darwin platform packages.
if artifact.Goos == "darwin" && artifact.Goarch == "all" {
for _, nodeArch := range []string{"x64", "arm64"} {
packagePath := filepath.Join(*packagesPath, fmt.Sprintf("pmg-darwin-%s", nodeArch))
if err := copyToBin(artifact.Path, packagePath, "pmg", *strict); err != nil {
log.Fatalf("sync darwin universal -> %s: %v", nodeArch, err)
}
}
continue
}
if err := syncBinary(artifact, *packagesPath, *strict); err != nil {
log.Fatalf("sync: %v", err)
}
case "Universal Binary":
// Retained for compatibility with older goreleaser versions that
// emitted a distinct type for universal binaries.
if artifact.Goos != "darwin" {
log.Printf("unexpected universal binary for goos=%s, skipping", artifact.Goos)
continue
}
for _, nodeArch := range []string{"x64", "arm64"} {
packagePath := filepath.Join(*packagesPath, fmt.Sprintf("pmg-darwin-%s", nodeArch))
if err := copyToBin(artifact.Path, packagePath, "pmg", *strict); err != nil {
log.Fatalf("sync darwin universal -> %s: %v", nodeArch, err)
}
}
}
}
if *setVersion != "" {
if err := setPackageVersions(*packagesPath, *setVersion); err != nil {
log.Fatalf("set-version: %v", err)
}
}
if *verifyBins {
if err := verifyPackageBins(*packagesPath); err != nil {
log.Fatalf("verify-bins: %v", err)
}
}
}
func syncBinary(artifact GoreleaserArtifact, packagesPath string, strict bool) error {
nodeArch, ok := goArchToNodeArchMap[artifact.Goarch]
if !ok {
nodeArch = artifact.Goarch
}
nodeOs, ok := goOsToNodeOsMap[artifact.Goos]
if !ok {
nodeOs = artifact.Goos
}
packagePath := filepath.Join(packagesPath, fmt.Sprintf("pmg-%s-%s", nodeOs, nodeArch))
binName := "pmg"
if artifact.Goos == "windows" {
binName = "pmg.exe"
}
return copyToBin(artifact.Path, packagePath, binName, strict)
}
func copyToBin(src, packagePath, binName string, strict bool) error {
if _, err := os.Stat(packagePath); os.IsNotExist(err) {
if strict {
return fmt.Errorf("package directory %s does not exist (add the platform package or remove the goreleaser target)", packagePath)
}
log.Printf("package directory %s does not exist, skipping", packagePath)
return nil
}
binDir := filepath.Join(packagePath, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil { //nolint:gosec // bin/ needs execute permission
return fmt.Errorf("create bin dir %s: %w", binDir, err)
}
dst := filepath.Join(binDir, binName)
log.Printf("copying %s -> %s", src, dst)
return copyFile(src, dst)
}
func copyFile(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return fmt.Errorf("open source: %w", err)
}
defer srcFile.Close() //nolint:errcheck // read-only; close error is negligible
dstFile, err := os.Create(dst)
if err != nil {
return fmt.Errorf("create destination: %w", err)
}
defer dstFile.Close() //nolint:errcheck // Sync() is called explicitly before return; deferred close is best-effort
if _, err := io.Copy(dstFile, srcFile); err != nil {
return fmt.Errorf("copy: %w", err)
}
if err := dstFile.Sync(); err != nil {
return fmt.Errorf("sync: %w", err)
}
srcInfo, err := os.Stat(src)
if err != nil {
return fmt.Errorf("stat source: %w", err)
}
return os.Chmod(dst, srcInfo.Mode())
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "sync-binaries",
"root": "scripts/sync-binaries",
"targets": {
"run": {
"executor": "nx:run-commands",
"dependsOn": [{"projects": "pmg", "target": "build-snapshot"}],
"options": {
"command": "go run ./scripts/sync-binaries/ --strict --verify-bins --artifacts-path dist/artifacts.json --packages-path ./packages",
"cwd": "{workspaceRoot}"
},
"inputs": ["goSyncSources", "{workspaceRoot}/dist/artifacts.json"],
"outputs": [
"{workspaceRoot}/packages/pmg-linux-x64/bin",
"{workspaceRoot}/packages/pmg-linux-arm64/bin",
"{workspaceRoot}/packages/pmg-darwin-x64/bin",
"{workspaceRoot}/packages/pmg-darwin-arm64/bin",
"{workspaceRoot}/packages/pmg-win32-x64/bin"
]
},
"run-release": {
"executor": "nx:run-commands",
"options": {
"command": "go run ./scripts/sync-binaries/ --strict --verify-bins --artifacts-path dist/artifacts.json --packages-path ./packages --set-version $VERSION",
"cwd": "{workspaceRoot}"
},
"inputs": ["goSyncSources", "{workspaceRoot}/dist/artifacts.json"],
"outputs": [
"{workspaceRoot}/packages/pmg-linux-x64/bin",
"{workspaceRoot}/packages/pmg-linux-arm64/bin",
"{workspaceRoot}/packages/pmg-darwin-x64/bin",
"{workspaceRoot}/packages/pmg-darwin-arm64/bin",
"{workspaceRoot}/packages/pmg-win32-x64/bin"
],
"cache": false
}
}
}
+137
View File
@@ -0,0 +1,137 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
var semverRe = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
// packageMeta holds the fields from package.json that drive sync decisions.
// Only the fields we actually branch on are declared; json.Unmarshal ignores
// the rest, so the full file content is never disturbed.
type packageMeta struct {
Private bool `json:"private"`
OS []string `json:"os"`
}
func readPackageMeta(path string) (*packageMeta, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var meta packageMeta
if err := json.Unmarshal(data, &meta); err != nil {
return nil, fmt.Errorf("parse: %w", err)
}
return &meta, nil
}
// setPackageVersions scans every immediate subdirectory of packagesPath for a
// package.json, skips those with "private": true, and writes version to the
// rest. Returns an error on the first failure.
func setPackageVersions(packagesPath, version string) error {
if !semverRe.MatchString(version) {
return fmt.Errorf("invalid version %q: must be x.y.z", version)
}
entries, err := os.ReadDir(packagesPath)
if err != nil {
return fmt.Errorf("read packages dir: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
path := filepath.Join(packagesPath, entry.Name(), "package.json")
if err := setVersionInPackageJSON(path, version); err != nil {
return fmt.Errorf("package %s: %w", entry.Name(), err)
}
}
return nil
}
// versionFieldRe is used to swap the "version" field in raw JSON bytes instead
// of round-tripping through a Go struct, which would re-serialize arrays and
// destroy inline formatting (e.g. "os": ["linux"] would expand to multi-line).
// Anchoring to start-of-line prevents false matches inside string values of
// other keys. Group 1 captures leading whitespace so indentation is unchanged.
var versionFieldRe = regexp.MustCompile(`(?m)^(\s*)"version"\s*:\s*"[^"]*"`)
// setVersionInPackageJSON reads the file at path, sets "version" to version,
// and writes it back. A missing file is silently skipped. Packages with
// "private": true are skipped unchanged.
//
// The replacement is done on the raw bytes so all other formatting (key order,
// inline arrays, whitespace) is preserved byte-for-byte.
func setVersionInPackageJSON(path, version string) error {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("read: %w", err)
}
var meta packageMeta
if err := json.Unmarshal(data, &meta); err != nil {
return fmt.Errorf("parse: %w", err)
}
if meta.Private {
return nil
}
repl := fmt.Appendf(nil, "${1}\"version\": \"%s\"", version)
updated := versionFieldRe.ReplaceAll(data, repl)
// 0o644: package.json must be world-readable for npm tooling.
return os.WriteFile(path, updated, 0o644) //nolint:gosec
}
// verifyPackageBins checks that every platform package under packagesPath has a
// non-empty bin/ directory. Platform packages are identified by the presence of
// an "os" field in their package.json; packages without that field (e.g. the
// meta/shim package) and private packages are skipped.
func verifyPackageBins(packagesPath string) error {
entries, err := os.ReadDir(packagesPath)
if err != nil {
return fmt.Errorf("read packages dir: %w", err)
}
var missing []string
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pkgJSONPath := filepath.Join(packagesPath, entry.Name(), "package.json")
meta, err := readPackageMeta(pkgJSONPath)
if os.IsNotExist(err) {
continue
}
if err != nil {
return fmt.Errorf("%s: %w", entry.Name(), err)
}
if meta.Private || len(meta.OS) == 0 {
continue
}
binDir := filepath.Join(packagesPath, entry.Name(), "bin")
binEntries, err := os.ReadDir(binDir)
if err != nil || len(binEntries) == 0 {
missing = append(missing, entry.Name())
}
}
if len(missing) > 0 {
return fmt.Errorf("platform packages missing bin/: %s", strings.Join(missing, ", "))
}
return nil
}
+246
View File
@@ -0,0 +1,246 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// makeDir creates a directory for test fixtures.
// 0o755 is the conventional permission for directories that need traversal.
func makeDir(t *testing.T, path string) {
t.Helper()
require.NoError(t, os.MkdirAll(path, 0o755)) //nolint:gosec
}
// writeTestFile writes a file for test fixtures.
// 0o644 is the conventional permission for config files like package.json.
func writeTestFile(t *testing.T, path string, content []byte) {
t.Helper()
require.NoError(t, os.WriteFile(path, content, 0o644)) //nolint:gosec
}
func writePkgJSON(t *testing.T, dir, name string, content map[string]any) string {
t.Helper()
pkgDir := filepath.Join(dir, name)
makeDir(t, pkgDir)
data, err := json.MarshalIndent(content, "", " ")
require.NoError(t, err)
path := filepath.Join(pkgDir, "package.json")
writeTestFile(t, path, append(data, '\n'))
return path
}
func readVersion(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
require.NoError(t, err)
var pkg map[string]any
require.NoError(t, json.Unmarshal(data, &pkg))
v, _ := pkg["version"].(string)
return v
}
func TestSetPackageVersions(t *testing.T) {
t.Run("sets version in all non-private packages", func(t *testing.T) {
dir := t.TempDir()
pathA := writePkgJSON(t, dir, "pkg-a", map[string]any{"name": "pkg-a", "version": "0.0.0"})
pathB := writePkgJSON(t, dir, "pkg-b", map[string]any{"name": "pkg-b", "version": "0.0.0"})
require.NoError(t, setPackageVersions(dir, "1.2.3"))
assert.Equal(t, "1.2.3", readVersion(t, pathA))
assert.Equal(t, "1.2.3", readVersion(t, pathB))
})
t.Run("skips private packages", func(t *testing.T) {
dir := t.TempDir()
pathPriv := writePkgJSON(t, dir, "private-pkg", map[string]any{
"name": "private-pkg",
"version": "0.0.0",
"private": true,
})
pathPub := writePkgJSON(t, dir, "public-pkg", map[string]any{"name": "public-pkg", "version": "0.0.0"})
require.NoError(t, setPackageVersions(dir, "2.0.0"))
assert.Equal(t, "0.0.0", readVersion(t, pathPriv))
assert.Equal(t, "2.0.0", readVersion(t, pathPub))
})
t.Run("skips subdirectories without package.json", func(t *testing.T) {
dir := t.TempDir()
makeDir(t, filepath.Join(dir, "no-pkg-json"))
pathA := writePkgJSON(t, dir, "pkg-a", map[string]any{"name": "pkg-a", "version": "0.0.0"})
require.NoError(t, setPackageVersions(dir, "3.0.0"))
assert.Equal(t, "3.0.0", readVersion(t, pathA))
})
t.Run("rejects invalid semver", func(t *testing.T) {
err := setPackageVersions(t.TempDir(), "not-a-version")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid version")
})
t.Run("returns error when packages dir is missing", func(t *testing.T) {
err := setPackageVersions("/nonexistent/path", "1.0.0")
require.Error(t, err)
})
}
func writeBinary(t *testing.T, dir, pkgName, binName string) {
t.Helper()
binDir := filepath.Join(dir, pkgName, "bin")
makeDir(t, binDir)
// 0o755: binary files need execute permission.
require.NoError(t, os.WriteFile(filepath.Join(binDir, binName), []byte("binary"), 0o755)) //nolint:gosec
}
func TestVerifyPackageBins(t *testing.T) {
t.Run("passes when all platform packages have binaries", func(t *testing.T) {
dir := t.TempDir()
writePkgJSON(t, dir, "cli-linux-x64", map[string]any{
"name": "cli-linux-x64", "version": "0.0.0", "os": []string{"linux"},
})
writeBinary(t, dir, "cli-linux-x64", "pmg")
writePkgJSON(t, dir, "cli-darwin-arm64", map[string]any{
"name": "cli-darwin-arm64", "version": "0.0.0", "os": []string{"darwin"},
})
writeBinary(t, dir, "cli-darwin-arm64", "pmg")
require.NoError(t, verifyPackageBins(dir))
})
t.Run("fails when a platform package has an empty bin/", func(t *testing.T) {
dir := t.TempDir()
writePkgJSON(t, dir, "cli-linux-x64", map[string]any{
"name": "cli-linux-x64", "version": "0.0.0", "os": []string{"linux"},
})
makeDir(t, filepath.Join(dir, "cli-linux-x64", "bin"))
err := verifyPackageBins(dir)
require.Error(t, err)
assert.Contains(t, err.Error(), "cli-linux-x64")
})
t.Run("fails when a platform package has no bin/ directory", func(t *testing.T) {
dir := t.TempDir()
writePkgJSON(t, dir, "cli-linux-x64", map[string]any{
"name": "cli-linux-x64", "version": "0.0.0", "os": []string{"linux"},
})
err := verifyPackageBins(dir)
require.Error(t, err)
assert.Contains(t, err.Error(), "cli-linux-x64")
})
t.Run("skips meta packages without os field", func(t *testing.T) {
dir := t.TempDir()
writePkgJSON(t, dir, "cli", map[string]any{
"name": "cli", "version": "0.0.0",
})
require.NoError(t, verifyPackageBins(dir))
})
t.Run("skips private packages", func(t *testing.T) {
dir := t.TempDir()
writePkgJSON(t, dir, "cli-private", map[string]any{
"name": "cli-private", "version": "0.0.0", "os": []string{"linux"}, "private": true,
})
require.NoError(t, verifyPackageBins(dir))
})
t.Run("reports multiple missing packages", func(t *testing.T) {
dir := t.TempDir()
writePkgJSON(t, dir, "cli-linux-x64", map[string]any{
"name": "cli-linux-x64", "version": "0.0.0", "os": []string{"linux"},
})
writePkgJSON(t, dir, "cli-darwin-x64", map[string]any{
"name": "cli-darwin-x64", "version": "0.0.0", "os": []string{"darwin"},
})
err := verifyPackageBins(dir)
require.Error(t, err)
assert.Contains(t, err.Error(), "cli-linux-x64")
assert.Contains(t, err.Error(), "cli-darwin-x64")
})
}
func TestSetVersionInPackageJSON(t *testing.T) {
t.Run("writes version field", func(t *testing.T) {
dir := t.TempDir()
path := writePkgJSON(t, dir, "pkg", map[string]any{"name": "pkg", "version": "0.0.0"})
require.NoError(t, setVersionInPackageJSON(filepath.Join(dir, "pkg", "package.json"), "4.5.6"))
assert.Equal(t, "4.5.6", readVersion(t, path))
})
t.Run("skips private package", func(t *testing.T) {
dir := t.TempDir()
path := writePkgJSON(t, dir, "pkg", map[string]any{"name": "pkg", "version": "0.0.0", "private": true})
require.NoError(t, setVersionInPackageJSON(filepath.Join(dir, "pkg", "package.json"), "4.5.6"))
assert.Equal(t, "0.0.0", readVersion(t, path))
})
t.Run("missing file is a no-op", func(t *testing.T) {
err := setVersionInPackageJSON("/nonexistent/package.json", "1.0.0")
require.NoError(t, err)
})
t.Run("preserves inline array formatting", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "pkg", "package.json")
makeDir(t, filepath.Dir(path))
original := `{
"name": "pkg",
"version": "0.0.0",
"os": ["linux"],
"cpu": ["x64"],
"files": ["bin/**"]
}
`
writeTestFile(t, path, []byte(original))
require.NoError(t, setVersionInPackageJSON(path, "1.2.3"))
data, err := os.ReadFile(path)
require.NoError(t, err)
content := string(data)
assert.Contains(t, content, `"version": "1.2.3"`)
assert.Contains(t, content, `"os": ["linux"]`)
assert.Contains(t, content, `"cpu": ["x64"]`)
assert.Contains(t, content, `"files": ["bin/**"]`)
})
t.Run("does not match version inside a string value", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "pkg", "package.json")
makeDir(t, filepath.Dir(path))
original := "{\n \"name\": \"pkg\",\n \"version\": \"0.0.0\",\n \"description\": \"see \\\"version\\\": \\\"1.0.0\\\" in docs\"\n}\n"
writeTestFile(t, path, []byte(original))
require.NoError(t, setVersionInPackageJSON(path, "2.0.0"))
data, err := os.ReadFile(path)
require.NoError(t, err)
content := string(data)
assert.Contains(t, content, `"version": "2.0.0"`)
assert.Contains(t, content, "\"description\": \"see \\\"version\\\": \\\"1.0.0\\\" in docs\"")
})
}