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
12 changes: 12 additions & 0 deletions cmd/wppackages/cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"golang.org/x/sync/errgroup"

"github.com/roots/wp-packages/internal/composer"
"github.com/roots/wp-packages/internal/packages"
"github.com/roots/wp-packages/internal/wporg"
)
Expand Down Expand Up @@ -154,6 +155,17 @@ func runUpdate(cmd *cobra.Command, args []string) error {
application.Logger.Debug("package has no tagged versions", "type", p.Type, "name", p.Name)
}

// Carry forward trunk_revision from DB (set by discover step)
pkg.TrunkRevision = p.TrunkRevision

// Compute content hash over normalized versions + trunk_revision
newHash := composer.HashVersions(pkg.VersionsJSON, pkg.TrunkRevision)
pkg.ContentHash = &newHash
if p.ContentHash == nil || *p.ContentHash != newHash {
now := time.Now().UTC()
pkg.ContentChangedAt = &now
}

now := time.Now().UTC()
pkg.LastSyncRunID = &syncRun.RunID

Expand Down
123 changes: 123 additions & 0 deletions internal/composer/format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package composer

import (
"fmt"
"hash/crc32"
"strings"
)

// PackageMeta holds optional metadata for Composer version entries.
type PackageMeta struct {
Description string
Homepage string
Author string
RequiresPHP string
LastUpdated string
TrunkRevision *int64
}

// ComposerVersion builds a single Composer version entry for a package.
func ComposerVersion(pkgType, slug, ver, downloadURL string, meta PackageMeta) map[string]any {
composerName := ComposerName(pkgType, slug)
composerType := "wordpress-plugin"
if pkgType == "theme" {
composerType = "wordpress-theme"
}

svnBase := fmt.Sprintf("https://plugins.svn.wordpress.org/%s", slug)
supportIssues := fmt.Sprintf("https://wordpress.org/support/plugin/%s", slug)
supportChangelog := fmt.Sprintf("https://wordpress.org/plugins/%s/#developers", slug)
if pkgType == "theme" {
svnBase = fmt.Sprintf("https://themes.svn.wordpress.org/%s", slug)
supportIssues = fmt.Sprintf("https://wordpress.org/support/theme/%s", slug)
supportChangelog = fmt.Sprintf("https://wordpress.org/themes/%s/#developers", slug)
}

ref := fmt.Sprintf("tags/%s", ver)
if pkgType == "theme" {
ref = ver
}
if ver == "dev-trunk" {
ref = "trunk"
if meta.TrunkRevision != nil {
ref = fmt.Sprintf("trunk@%d", *meta.TrunkRevision)
}
}

entry := map[string]any{
"name": composerName,
"version": ver,
"type": composerType,
"source": map[string]any{
"type": "svn",
"url": svnBase + "/",
"reference": ref,
},
"require": map[string]any{
"composer/installers": "~1.0|~2.0",
},
"support": map[string]any{
"source": svnBase,
"issues": supportIssues,
"changelog": supportChangelog,
},
"uid": crc32.ChecksumIEEE([]byte(fmt.Sprintf("%s/%s", composerName, ver))),
}

if ver != "dev-trunk" && downloadURL != "" {
entry["dist"] = map[string]any{
"type": "zip",
"url": downloadURL,
}
}

if meta.Description != "" {
entry["description"] = meta.Description
}
if meta.Homepage != "" {
entry["homepage"] = meta.Homepage
}
if meta.Author != "" {
entry["authors"] = []map[string]any{{"name": meta.Author}}
}
if meta.RequiresPHP != "" {
req := entry["require"].(map[string]any)
req["php"] = ">=" + meta.RequiresPHP
}
if meta.LastUpdated != "" {
entry["time"] = meta.LastUpdated
}

return entry
}

// ComposerName returns the Composer package name for a WordPress package.
func ComposerName(pkgType, slug string) string {
if pkgType == "theme" {
return "wp-theme/" + slug
}
return "wp-plugin/" + slug
}

// DownloadURL returns the WordPress.org download URL for a specific version.
func DownloadURL(pkgType, slug, version string) string {
if pkgType == "theme" {
return fmt.Sprintf("https://downloads.wordpress.org/theme/%s.%s.zip", slug, version)
}
return fmt.Sprintf("https://downloads.wordpress.org/plugin/%s.%s.zip", slug, version)
}

// VendorFromComposerName extracts the path portion for filesystem layout.
// "wp-plugin/akismet" → "wp-plugin/akismet"
func VendorFromComposerName(name string) string {
return name
}

// SlugFromComposerName extracts just the slug: "wp-plugin/akismet" → "akismet"
func SlugFromComposerName(name string) string {
parts := strings.SplitN(name, "/", 2)
if len(parts) == 2 {
return parts[1]
}
return name
}
48 changes: 48 additions & 0 deletions internal/composer/hash.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package composer

import (
"crypto/sha256"
"encoding/json"
"fmt"
"sort"
)

// DeterministicJSON produces JSON with recursively sorted keys for reproducible hashes.
func DeterministicJSON(v any) ([]byte, error) {
sorted := sortKeys(v)
return json.Marshal(sorted)
}

// HashJSON returns the SHA-256 hex digest of deterministic JSON.
func HashJSON(v any) (string, []byte, error) {
data, err := DeterministicJSON(v)
if err != nil {
return "", nil, fmt.Errorf("encoding JSON: %w", err)
}
h := sha256.Sum256(data)
return fmt.Sprintf("%x", h), data, nil
}

func sortKeys(v any) any {
switch val := v.(type) {
case map[string]any:
keys := make([]string, 0, len(val))
for k := range val {
keys = append(keys, k)
}
sort.Strings(keys)
sorted := make(map[string]any, len(val))
for _, k := range keys {
sorted[k] = sortKeys(val[k])
}
return sorted
case []any:
result := make([]any, len(val))
for i, item := range val {
result[i] = sortKeys(item)
}
return result
default:
return v
}
}
129 changes: 129 additions & 0 deletions internal/composer/serialize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package composer

import (
"crypto/sha256"
"encoding/json"
"fmt"
"strconv"
"strings"

"github.com/roots/wp-packages/internal/version"
)

// FileOutput holds serialized Composer JSON and its R2 object key.
type FileOutput struct {
Data []byte
Key string // R2 object key, e.g. "p2/wp-plugin/akismet.json"
}

// PackageFiles holds the output files for a single package.
type PackageFiles struct {
Tagged FileOutput // p2/wp-plugin/akismet.json (always present for active packages)
Dev FileOutput // p2/wp-plugin/akismet~dev.json (empty if no dev versions)
}

// HashVersions computes a content hash over the normalized versions_json and
// trunk_revision. trunk_revision is included because it affects the serialized
// dev-trunk output (source.reference includes trunk@<rev>) even though it's
// not part of versions_json.
//
// versionsJSON is already deterministic — json.Marshal sorts map keys, and
// NormalizeAndStoreVersions always produces the same output for the same input.
// So we hash the string directly rather than round-tripping through parse/sort.
func HashVersions(versionsJSON string, trunkRevision *int64) string {
h := sha256.New()
h.Write([]byte(versionsJSON))
if trunkRevision != nil {
h.Write([]byte(strconv.FormatInt(*trunkRevision, 10)))
}
return fmt.Sprintf("%x", h.Sum(nil))
}

// SerializePackage splits versions_json into tagged and dev files, computes a
// content hash over the full deterministic versions_json, and returns the hash
// plus the serialized Composer JSON for each output file.
//
// The hash is computed over the full normalized versions_json (all versions),
// not the output files. This means any version change triggers re-upload of
// both files, which is simpler than tracking separate hashes.
//
// Plugins produce both tagged and dev files. Themes produce only tagged.
// Plugins with zero tagged versions get dev-trunk in the main file.
func SerializePackage(pkgType, name string, versionsJSON string, meta PackageMeta) (hash string, files PackageFiles, err error) {
// Parse and re-normalize versions
var versions map[string]string
if err := json.Unmarshal([]byte(versionsJSON), &versions); err != nil {
return "", PackageFiles{}, fmt.Errorf("parsing versions_json for %s/%s: %w", pkgType, name, err)
}
versions = version.NormalizeVersions(versions)

// Compute content hash over versions + trunk_revision.
// We hash the re-normalized JSON (not the raw input) so the hash matches
// what HashVersions would produce on the same DB row.
normalized, err := json.Marshal(versions)
if err != nil {
return "", PackageFiles{}, fmt.Errorf("marshaling normalized versions for %s/%s: %w", pkgType, name, err)
}
hash = HashVersions(string(normalized), meta.TrunkRevision)

composerName := ComposerName(pkgType, name)

// Split versions into tagged and dev
taggedVersions := make(map[string]any)
for ver, dlURL := range versions {
if !strings.HasPrefix(ver, "dev-") {
taggedVersions[ver] = ComposerVersion(pkgType, name, ver, dlURL, meta)
}
}

var devVersions map[string]any
if pkgType == "plugin" {
devVersions = map[string]any{
"dev-trunk": ComposerVersion(pkgType, name, "dev-trunk", "", meta),
}
}

// Main file: tagged versions, or dev-trunk for trunk-only plugins
mainVersions := taggedVersions
if len(mainVersions) == 0 && devVersions != nil {
mainVersions = devVersions
}
if len(mainVersions) == 0 {
// Theme with no tagged versions — nothing to serialize
return hash, PackageFiles{}, nil
}

taggedPayload := map[string]any{
"packages": map[string]any{
composerName: mainVersions,
},
}
taggedData, err := DeterministicJSON(taggedPayload)
if err != nil {
return "", PackageFiles{}, fmt.Errorf("serializing tagged %s: %w", composerName, err)
}

files.Tagged = FileOutput{
Data: taggedData,
Key: fmt.Sprintf("p2/%s.json", composerName),
}

// Dev file: plugins only
if devVersions != nil {
devPayload := map[string]any{
"packages": map[string]any{
composerName: devVersions,
},
}
devData, err := DeterministicJSON(devPayload)
if err != nil {
return "", PackageFiles{}, fmt.Errorf("serializing dev %s: %w", composerName, err)
}
files.Dev = FileOutput{
Data: devData,
Key: fmt.Sprintf("p2/%s~dev.json", composerName),
}
}

return hash, files, nil
}
Loading
Loading