Skip to content

Commit faf97fe

Browse files
committed
feat: implement PR-first canonical release submission
Replace the stubbed --create-pr flag on `apx release submit` with a fully working PR-based submission flow. The subtree push path is removed from release submit; PR is now the only submit mechanism. Key changes: Publisher (internal/publisher/): - Add PRNumber, PRURL, PRBranch, CIProvider, CIRunURL fields to ReleaseManifest with YAML/JSON round-trip support - Add SubmitReleaseWithPR: clone canonical, create release branch (apx/release/<api-id>/<version>), copy snapshot, commit, force-push, create PR with idempotent retry via FindExistingPR - Add FindExistingPR: query gh pr list for existing open PRs on a branch - Add ComputeReleaseBranchName: deterministic branch naming helper - Make runGit/runGitIn stubable via function variables for testability - Update FormatManifestReport to display PR and CI metadata Command layer (cmd/apx/commands/release.go): - Rewrite releaseSubmitAction: remove subtree path and --create-pr flag, wire SubmitReleaseWithPR as the sole submit mechanism - Add state guards: prepared → submit, canonical-pr-open → report existing PR, package-published → no-op, failed → error with hint - Add dry-run preview: show branch name, snapshot files, go.mod preview - Add gh CLI preflight check with auth hint - Add CI provenance: detect GitHub Actions/GitLab CI/Jenkins, embed run URL in PR body and record in manifest - Record PR metadata in manifest after successful submission Tests: - Unit tests for PR metadata round-trip, FindExistingPR (4 cases), ComputeReleaseBranchName, SubmitReleaseWithPR (branch naming, existing PR detection, CI provenance) - Testscript scenarios: missing manifest, failed state, published state, PR-open retry, inspect PR metadata display, dry-run preview Docs: - Update release-commands.md submit section for PR-only flow - Update publishing/overview.md release pipeline description - CLI reference and publishing docs consistency fixes
1 parent 1c974f0 commit faf97fe

22 files changed

Lines changed: 1838 additions & 110 deletions

File tree

cmd/apx/commands/release.go

Lines changed: 140 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -338,26 +338,25 @@ func newReleaseSubmitCmd() *cobra.Command {
338338
cmd := &cobra.Command{
339339
Use: "submit",
340340
Short: "Submit a prepared release to the canonical repo",
341-
Long: `Submit pushes the prepared release (from 'apx release prepare') to
342-
the canonical repository. It reads .apx-release.yaml and either
343-
pushes via subtree or creates a pull request.
341+
Long: `Submit opens a pull request on the canonical repository with the
342+
prepared release content (from 'apx release prepare'). It reads
343+
.apx-release.yaml, clones the canonical repo, pushes the snapshot
344+
to a release branch, and creates a PR.
344345
345-
This operation is idempotent: if the same version with the same
346-
content has already been published, it will report success without
347-
changing anything.
346+
This operation is idempotent: re-running after a partial failure will
347+
detect existing branches and PRs, recovering gracefully without
348+
creating duplicates.
348349
349350
Examples:
350351
apx release submit
351-
apx release submit --create-pr`,
352+
apx release submit --dry-run`,
352353
RunE: releaseSubmitAction,
353354
}
354-
cmd.Flags().Bool("create-pr", false, "Create a pull request instead of pushing directly")
355355
cmd.Flags().Bool("dry-run", false, "Show what would be submitted without actually doing it")
356356
return cmd
357357
}
358358

359359
func releaseSubmitAction(cmd *cobra.Command, _ []string) error {
360-
createPR, _ := cmd.Flags().GetBool("create-pr")
361360
dryRun, _ := cmd.Flags().GetBool("dry-run")
362361

363362
// Read manifest
@@ -369,110 +368,172 @@ func releaseSubmitAction(cmd *cobra.Command, _ []string) error {
369368
)
370369
}
371370

372-
// Verify state
373-
if manifest.State != publisher.StatePrepared {
374-
if manifest.State == publisher.StatePackagePublished {
375-
ui.Success("Release already published — nothing to do")
371+
// ── State guards ─────────────────────────────────────────────────
372+
switch manifest.State {
373+
case publisher.StatePrepared:
374+
// Expected state — proceed
375+
case publisher.StateCanonicalPROpen:
376+
// Already submitted — report existing PR and exit
377+
if manifest.PRURL != "" {
378+
ui.Success("Release already submitted — PR is open")
379+
ui.Info("PR: %s", manifest.PRURL)
380+
if manifest.PRBranch != "" {
381+
ui.Info("Branch: %s", manifest.PRBranch)
382+
}
376383
return nil
377384
}
378-
if manifest.State == publisher.StateFailed {
379-
return publisher.NewPublishError(
380-
publisher.ErrCodeValidationFailed,
381-
fmt.Sprintf("release is in failed state: %s", manifest.Error.Message),
382-
).WithHint("Fix the issue and re-run 'apx release prepare'")
383-
}
385+
// PR metadata missing — fall through to re-submit
386+
case publisher.StatePackagePublished:
387+
ui.Success("Release already published — nothing to do")
388+
return nil
389+
case publisher.StateFailed:
390+
return publisher.NewPublishError(
391+
publisher.ErrCodeValidationFailed,
392+
fmt.Sprintf("release is in failed state: %s", manifest.Error.Message),
393+
).WithHint("Fix the issue and re-run 'apx release prepare'")
394+
default:
384395
return fmt.Errorf("unexpected manifest state %q — expected 'prepared'", manifest.State)
385396
}
386397

387-
repoPath, err := os.Getwd()
388-
if err != nil {
389-
return fmt.Errorf("failed to get current directory: %w", err)
390-
}
391-
392-
gitDir := filepath.Join(repoPath, ".git")
393-
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
394-
return publisher.NewPublishError(publisher.ErrCodeNotGitRepo, "not in a git repository")
395-
}
398+
// ── Dry-run path ─────────────────────────────────────────────────
399+
if dryRun {
400+
branch := publisher.ComputeReleaseBranchName(manifest.APIID, manifest.RequestedVersion)
401+
ui.Info("Dry-run mode: showing what would be submitted")
402+
ui.Info("")
403+
ui.Info("Branch: %s", branch)
404+
ui.Info("")
396405

397-
// Generate go.mod if needed
398-
goModulePath := manifest.GoModule
399-
if goModulePath != "" {
400-
api := &config.APIIdentity{
401-
ID: manifest.APIID, Format: manifest.Format,
402-
Domain: manifest.Domain, Name: manifest.Name, Line: manifest.Line,
406+
// List snapshot files
407+
snapshotDir := manifest.SourcePath
408+
if _, statErr := os.Stat(snapshotDir); statErr == nil {
409+
ui.Info("Snapshot files:")
410+
_ = filepath.Walk(snapshotDir, func(path string, info os.FileInfo, walkErr error) error {
411+
if walkErr != nil || info.IsDir() {
412+
return walkErr
413+
}
414+
rel, _ := filepath.Rel(snapshotDir, path)
415+
ui.Info(" %s", rel)
416+
return nil
417+
})
418+
ui.Info("")
403419
}
404-
goModDir := config.DeriveGoModDir(api)
405-
goModPath := filepath.Join(repoPath, goModDir, "go.mod")
406420

407-
if _, readErr := os.ReadFile(goModPath); os.IsNotExist(readErr) {
408-
content, genErr := publisher.GenerateGoMod(goModulePath, "1.21")
409-
if genErr != nil {
410-
manifest.Fail(string(publisher.ErrCodeGoModMismatch), genErr.Error(), "submit")
411-
_ = publisher.WriteManifest(manifest, ".apx-release.yaml")
412-
return fmt.Errorf("generating go.mod: %w", genErr)
413-
}
414-
if dryRun {
415-
ui.Info("Would generate go.mod at %s", goModDir)
416-
} else {
417-
if mkErr := os.MkdirAll(filepath.Join(repoPath, goModDir), 0o755); mkErr != nil {
418-
return fmt.Errorf("creating go.mod directory: %w", mkErr)
419-
}
420-
if writeErr := os.WriteFile(goModPath, content, 0o644); writeErr != nil {
421-
return fmt.Errorf("writing go.mod: %w", writeErr)
422-
}
423-
ui.Info("Generated go.mod at %s", goModDir)
421+
// Show go.mod preview if applicable
422+
if manifest.GoModule != "" {
423+
content, genErr := publisher.GenerateGoMod(manifest.GoModule, "1.21")
424+
if genErr == nil {
425+
ui.Info("go.mod preview:")
426+
ui.Info("%s", string(content))
424427
}
425428
}
426-
}
427429

428-
if dryRun {
429-
ui.Info("Dry-run mode: showing what would be submitted")
430-
ui.Info("")
431430
fmt.Print(publisher.FormatManifestReport(manifest))
432431
ui.Info("")
433432
ui.Success("Would submit release successfully")
434433
return nil
435434
}
436435

437-
// Publish via subtree
436+
// ── Preflight: gh CLI check ──────────────────────────────────────
437+
if err := publisher.CheckGHCLI(); err != nil {
438+
return publisher.NewPublishError(
439+
publisher.ErrCodePRCreationFailed,
440+
"GitHub CLI (gh) is required for release submission",
441+
).WithHint("Install gh from https://cli.github.com and run: gh auth login")
442+
}
443+
444+
// ── Submit via PR ────────────────────────────────────────────────
438445
ui.Info("Submitting release %s @ %s", manifest.APIID, manifest.RequestedVersion)
439446

440-
subtreePublisher := publisher.NewSubtreePublisher(repoPath)
441-
commitHash, err := subtreePublisher.PublishModule(
442-
manifest.SourcePath, manifest.CanonicalRepo, manifest.RequestedVersion,
443-
)
447+
// Build CI provenance extra for PR body
448+
prBodyExtra := buildCIProvenance()
449+
450+
resp, err := publisher.SubmitReleaseWithPR(manifest, manifest.SourcePath, prBodyExtra)
444451
if err != nil {
445-
manifest.Fail(string(publisher.ErrCodeSubtreeFailed), err.Error(), "submit")
452+
manifest.Fail(string(publisher.ErrCodePRCreationFailed), err.Error(), "submit")
446453
_ = publisher.WriteManifest(manifest, ".apx-release.yaml")
447454
return &publisher.PublishError{
448-
Code: publisher.ErrCodeSubtreeFailed,
449-
Message: fmt.Sprintf("subtree publish failed: %v", err),
450-
Hint: "Check git status and try 'apx release submit' again",
455+
Code: publisher.ErrCodePRCreationFailed,
456+
Message: fmt.Sprintf("release submission failed: %v", err),
457+
Hint: "Check gh auth status and try 'apx release submit' again",
451458
}
452459
}
453460

454-
if err := manifest.SetState(publisher.StateSubmitted); err != nil {
455-
return err
461+
// ── Record PR metadata in manifest ───────────────────────────────
462+
manifest.PRNumber = resp.Number
463+
manifest.PRURL = resp.HTMLURL
464+
manifest.PRBranch = publisher.ComputeReleaseBranchName(manifest.APIID, manifest.RequestedVersion)
465+
466+
// Record CI provenance if running in CI
467+
if prBodyExtra != "" {
468+
if os.Getenv("GITHUB_ACTIONS") == "true" {
469+
manifest.CIProvider = "github-actions"
470+
serverURL := os.Getenv("GITHUB_SERVER_URL")
471+
repo := os.Getenv("GITHUB_REPOSITORY")
472+
runID := os.Getenv("GITHUB_RUN_ID")
473+
if serverURL != "" && repo != "" && runID != "" {
474+
manifest.CIRunURL = fmt.Sprintf("%s/%s/actions/runs/%s", serverURL, repo, runID)
475+
}
476+
} else if os.Getenv("GITLAB_CI") == "true" {
477+
manifest.CIProvider = "gitlab-ci"
478+
manifest.CIRunURL = os.Getenv("CI_PIPELINE_URL")
479+
} else if os.Getenv("JENKINS_URL") != "" {
480+
manifest.CIProvider = "jenkins"
481+
manifest.CIRunURL = os.Getenv("BUILD_URL")
482+
}
456483
}
457484

458-
manifest.SourceCommit = commitHash
459-
_ = publisher.WriteManifest(manifest, ".apx-release.yaml")
485+
if err := manifest.SetState(publisher.StateCanonicalPROpen); err != nil {
486+
return err
487+
}
488+
if writeErr := publisher.WriteManifest(manifest, ".apx-release.yaml"); writeErr != nil {
489+
return fmt.Errorf("writing manifest: %w", writeErr)
490+
}
460491

461492
ui.Success("✓ Release submitted successfully")
462-
ui.Info("Commit: %s", commitHash)
493+
ui.Info("PR: %s", manifest.PRURL)
494+
if manifest.PRNumber != 0 {
495+
ui.Info("PR #: %d", manifest.PRNumber)
496+
}
497+
ui.Info("Branch: %s", manifest.PRBranch)
463498
ui.Info("Tag: %s", manifest.Tag)
464499

465-
if createPR {
466-
if err := manifest.SetState(publisher.StateCanonicalPROpen); err != nil {
467-
return err
500+
return nil
501+
}
502+
503+
// buildCIProvenance returns extra PR body content with CI provenance
504+
// information, or an empty string if not running in CI.
505+
func buildCIProvenance() string {
506+
// GitHub Actions
507+
if os.Getenv("GITHUB_ACTIONS") == "true" {
508+
serverURL := os.Getenv("GITHUB_SERVER_URL")
509+
repo := os.Getenv("GITHUB_REPOSITORY")
510+
runID := os.Getenv("GITHUB_RUN_ID")
511+
if serverURL != "" && repo != "" && runID != "" {
512+
runURL := fmt.Sprintf("%s/%s/actions/runs/%s", serverURL, repo, runID)
513+
return fmt.Sprintf("**CI**: github-actions\n**Run**: %s", runURL)
468514
}
469-
_ = publisher.WriteManifest(manifest, ".apx-release.yaml")
470-
ui.Info("Creating pull request...")
471-
return publisher.NewPublishError(publisher.ErrCodePushFailed,
472-
"PR creation not yet implemented").WithHint("Push directly or implement PR flow")
515+
return "**CI**: github-actions"
473516
}
474517

475-
return nil
518+
// GitLab CI
519+
if os.Getenv("GITLAB_CI") == "true" {
520+
pipelineURL := os.Getenv("CI_PIPELINE_URL")
521+
if pipelineURL != "" {
522+
return fmt.Sprintf("**CI**: gitlab-ci\n**Run**: %s", pipelineURL)
523+
}
524+
return "**CI**: gitlab-ci"
525+
}
526+
527+
// Jenkins
528+
if os.Getenv("JENKINS_URL") != "" {
529+
buildURL := os.Getenv("BUILD_URL")
530+
if buildURL != "" {
531+
return fmt.Sprintf("**CI**: jenkins\n**Run**: %s", buildURL)
532+
}
533+
return "**CI**: jenkins"
534+
}
535+
536+
return ""
476537
}
477538

478539
// ---------------------------------------------------------------------------

cmd/apx/commands/semver.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Rules:
3434
3535
Lifecycle pre-release mapping:
3636
- experimental → -alpha.N
37-
- beta → -beta.N
37+
- preview → -beta.N (beta accepted as alias for preview)
3838
- stable → normal semver (no prerelease)
3939
- deprecated → allowed with warning
4040
- sunset → blocked`,
@@ -44,7 +44,7 @@ Lifecycle pre-release mapping:
4444
cmd.Flags().String("against", "", "Git reference or path to compare against (required)")
4545
_ = cmd.MarkFlagRequired("against")
4646
cmd.Flags().String("api-id", "", "API ID (e.g. proto/payments/ledger/v1)")
47-
cmd.Flags().String("lifecycle", "", "Lifecycle state (experimental, beta, stable, deprecated, sunset)")
47+
cmd.Flags().String("lifecycle", "", "Lifecycle state (experimental, preview, stable, deprecated, sunset)")
4848
cmd.Flags().StringP("format", "f", "", "Schema format (proto, openapi, avro, jsonschema, parquet)")
4949
return cmd
5050
}

docs/cli-reference/configuration.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ version: 1
8888
| `api.domain` | string | no | | | Business domain for the API |
8989
| `api.name` | string | no | | | API name within the domain |
9090
| `api.line` | string | no | | | API compatibility line (e.g. v1, v2) |
91-
| `api.lifecycle` | string | no | | experimental, beta, stable, deprecated, sunset | Maturity/support state of this API line |
91+
| `api.lifecycle` | string | no | | experimental, preview, stable, deprecated, sunset | Maturity/support state of this API line (`beta` accepted as alias for `preview`) |
9292
| `source` | struct | no | | | Canonical source repository identity |
9393
| `source.repo` | string | no | | | Canonical source repository (e.g. github.com/acme/apis) |
9494
| `source.path` | string | no | | | Path within the canonical repo (derived from api.id) |
@@ -210,19 +210,23 @@ api:
210210
domain: payments
211211
name: ledger
212212
line: v1
213-
lifecycle: beta
213+
lifecycle: preview
214214
```
215215

216216
The `line` field represents the API compatibility line (`v1`, `v2`, etc.). Only breaking changes create a new line. The `lifecycle` field tracks the maturity state independently from the release version.
217217

218218
| Lifecycle | Meaning |
219-
|-----------|---------|
219+
|-----------|--------|
220220
| `experimental` | Early exploration, no compatibility guarantees |
221-
| `beta` | Feature-complete but may change before GA |
221+
| `preview` | API surface is stabilizing; minor breaking changes still possible |
222222
| `stable` | Production-ready, backward-compatible within the API line |
223223
| `deprecated` | Superseded by a newer line, still supported |
224224
| `sunset` | End-of-life, will be removed |
225225

226+
:::{note}
227+
`beta` is accepted as a backward-compatible alias for `preview`. New projects should use `preview`.
228+
:::
229+
226230
### `source`
227231

228232
Identifies where the canonical source lives.

docs/cli-reference/index.md

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,6 @@
22

33
Complete reference for all APX commands and options.
44

5-
:::{note}
6-
Full per-command documentation is in progress. See sub-pages for individual command groups once available.
7-
:::
8-
95
## Command Categories
106

117
APX commands are organized into logical categories:
@@ -31,7 +27,8 @@ APX commands are organized into logical categories:
3127

3228
:::{grid-item-card} **Publishing**
3329
^^^
34-
- `apx publish` - Publish to canonical
30+
- `apx publish` - Publish to canonical (one-shot)
31+
- `apx release` - Multi-step release pipeline
3532
- `apx semver suggest` - Suggest version bump
3633
:::
3734

@@ -83,9 +80,20 @@ apx sync # updates go.work overlays
8380
apx unlink proto/payments/ledger/v1
8481
go get github.com/myorg/apis/proto/payments/ledger@v1.2.3
8582

86-
# Publish from app repo
87-
apx publish --module-path=internal/apis/proto/domain/api/v1 \
88-
--canonical-repo=github.com/org/apis
83+
# Quick publish from app repo
84+
apx publish proto/payments/ledger/v1 --version v1.0.0 --lifecycle stable
85+
86+
# Production release pipeline (multi-step)
87+
apx release prepare proto/payments/ledger/v1 --version v1.0.0
88+
apx release submit
89+
apx release finalize # run by canonical CI
90+
91+
# Release history and inspection
92+
apx release history proto/payments/ledger/v1
93+
apx release inspect
94+
95+
# Lifecycle promotion
96+
apx release promote proto/payments/ledger/v1 --to stable --version v1.0.0
8997
```
9098

9199
**Application code using canonical imports:**
@@ -237,6 +245,7 @@ apx completion fish > ~/.config/fish/completions/apx.fish
237245

238246
- [Learn core commands](core-commands.md) for project setup
239247
- [Master dependency commands](dependency-commands.md) for API management
240-
- [Understand publishing commands](publishing-commands.md) for releases
248+
- [Understand publishing commands](publishing-commands.md) for one-shot publishing
249+
- [Master release commands](release-commands.md) for production release pipelines
241250
- [Use validation commands](validation-commands.md) for quality assurance
242251
- [Explore utility commands](utility-commands.md) for daily workflows

0 commit comments

Comments
 (0)