Skip to content

Commit 1aab717

Browse files
danielkovclaude
andauthored
feat(ci): add fanout finalization flow for parallel SDK generation (#1935)
## Summary - add new `speakeasy ci fanout-finalize` command to aggregate worker branches into a single PR branch - implement fanout finalization action to checkout base, cherry-pick worker commits, aggregate reports, remove ephemeral changelog/report files, squash, and force-push - update CI action internals to write per-target reports early in matrix mode and reuse PR upsert logic - remove obsolete shared-branch prep flow (`resolve-branch`) used by prior fanout attempts - cleanup worker branches after successful finalization (default enabled) ## New Flow 1. A prep step computes the targets and base branch only. 2. Each worker job checks out the base branch and generates exactly one target. 3. Each worker commits generated output plus that target's `.speakeasy/reports/<target>.json` to its own worker branch. 4. Finalize checks out the base branch and cherry-picks all worker commits. 5. Finalize aggregates report JSON files to build merged PR title/body metadata. 6. Finalize runs cleanup of ephemeral files (`.speakeasy/reports/*` and `.speakeasy/logs/changes/*`). 7. Finalize soft-squashes to one commit, force-pushes the PR branch, and creates/updates the PR. 8. Finalize deletes worker branches (can be disabled with `--cleanup-workers=false`). ## Why This Replaces Prior Attempts - eliminates parallel jobs pushing to the same branch - removes artifact/cache coupling for report persistence - ensures changelog/report artifacts are treated as ephemeral and do not persist in final PR commits - makes reruns idempotent by rewriting the PR branch from base + worker commits ## Testing - go test ./cmd/ci ./internal/ci/actions - go test ./cmd/ci ./internal/ci/actions ./internal/ci/git --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5581d59 commit 1aab717

File tree

11 files changed

+471
-172
lines changed

11 files changed

+471
-172
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
name: PR Pre-release
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, reopened, labeled]
6+
7+
concurrency:
8+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
9+
cancel-in-progress: true
10+
11+
permissions:
12+
contents: write
13+
pull-requests: write
14+
15+
jobs:
16+
build-artifacts:
17+
name: Build PR Artifacts
18+
if: ${{ github.event.pull_request.draft == false && contains(github.event.pull_request.labels.*.name, 'pre-release') }}
19+
runs-on:
20+
group: ubuntu-latest-large
21+
steps:
22+
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
23+
with:
24+
fetch-depth: 0
25+
ref: ${{ github.event.pull_request.head.sha }}
26+
- name: Set short SHA
27+
run: echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
28+
- name: Check if release already exists
29+
id: check-release
30+
env:
31+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
32+
run: |
33+
if gh release view "v0.0.0-${SHORT_SHA}" &>/dev/null; then
34+
echo "exists=true" >> "$GITHUB_OUTPUT"
35+
else
36+
echo "exists=false" >> "$GITHUB_OUTPUT"
37+
fi
38+
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
39+
if: steps.check-release.outputs.exists == 'false'
40+
with:
41+
go-version-file: "go.mod"
42+
- name: Configure git for private modules
43+
if: steps.check-release.outputs.exists == 'false'
44+
env:
45+
GIT_AUTH_TOKEN: ${{ secrets.BOT_REPO_TOKEN }}
46+
run: git config --global url."https://speakeasybot:${GIT_AUTH_TOKEN}@github.com".insteadOf "https://github.com"
47+
- uses: goreleaser/goreleaser-action@9ed2f89a662bf1735a48bc8557fd212fa902bebf # v6.1.0
48+
if: steps.check-release.outputs.exists == 'false'
49+
with:
50+
version: '~> v2'
51+
args: release --snapshot --clean --config .goreleaser.pr.yaml
52+
- name: Create release
53+
if: steps.check-release.outputs.exists == 'false'
54+
env:
55+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
56+
run: |
57+
gh release create "v0.0.0-${SHORT_SHA}" \
58+
--title "PR Build (${SHORT_SHA})" \
59+
--notes "Built from commit ${{ github.event.pull_request.head.sha }} on PR #${{ github.event.pull_request.number }}" \
60+
--prerelease \
61+
dist/*.zip dist/checksums.txt
62+
- name: Comment install instructions on PR
63+
if: steps.check-release.outputs.exists == 'false'
64+
env:
65+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
66+
run: |
67+
gh pr comment ${{ github.event.pull_request.number }} \
68+
--body "### Pre-release build ready
69+
70+
Install with:
71+
\`\`\`sh
72+
curl -fsSL https://raw.githubusercontent.com/speakeasy-api/speakeasy/main/install.sh | VERSION=0.0.0-${SHORT_SHA} sh
73+
\`\`\`
74+
75+
Built from commit \`${{ github.event.pull_request.head.sha }}\`"

.github/workflows/validate.yml

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -116,37 +116,3 @@ jobs:
116116
- run: go test -json -v -timeout=20m ./... | gotestfmt
117117
env:
118118
SPEAKEASY_API_KEY: ${{ secrets.SPEAKEASY_API_KEY }}
119-
build-artifacts:
120-
name: Build PR Artifacts
121-
if: ${{ github.event.pull_request.draft == false }}
122-
permissions:
123-
contents: write
124-
runs-on:
125-
group: ubuntu-latest-large
126-
steps:
127-
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
128-
with:
129-
fetch-depth: 0
130-
ref: ${{ github.event.pull_request.head.sha }}
131-
- name: Set short SHA
132-
run: echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
133-
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
134-
with:
135-
go-version-file: "go.mod"
136-
- name: Configure git for private modules
137-
env:
138-
GIT_AUTH_TOKEN: ${{ secrets.BOT_REPO_TOKEN }}
139-
run: git config --global url."https://speakeasybot:${GIT_AUTH_TOKEN}@github.com".insteadOf "https://github.com"
140-
- uses: goreleaser/goreleaser-action@9ed2f89a662bf1735a48bc8557fd212fa902bebf # v6.1.0
141-
with:
142-
version: '~> v2'
143-
args: release --snapshot --clean --config .goreleaser.pr.yaml
144-
- name: Create release
145-
env:
146-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
147-
run: |
148-
gh release create "v0.0.0-${SHORT_SHA}" \
149-
--title "PR Build (${SHORT_SHA})" \
150-
--notes "Built from commit ${{ github.event.pull_request.head.sha }} on PR #${{ github.event.pull_request.number }}" \
151-
--prerelease \
152-
dist/*.zip dist/checksums.txt

cmd/ci/ci.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ var CICmd = &model.CommandGroup{
1919
finalizeCmd,
2020
prDescriptionCmd,
2121
createOrUpdatePRCmd,
22+
fanoutFinalizeCmd,
2223
publishEventCmd,
2324

2425
tagCmd,
2526
ciTestCmd,
2627
logResultCmd,
27-
resolveBranchCmd,
2828
},
2929
}

cmd/ci/fanout_finalize.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package ci
2+
3+
import (
4+
"context"
5+
"os"
6+
7+
"github.com/speakeasy-api/speakeasy/internal/ci/actions"
8+
"github.com/speakeasy-api/speakeasy/internal/model"
9+
"github.com/speakeasy-api/speakeasy/internal/model/flag"
10+
)
11+
12+
type fanoutFinalizeFlags struct {
13+
GithubAccessToken string `json:"github-access-token"`
14+
BaseBranch string `json:"base-branch"`
15+
WorkerBranches string `json:"worker-branches"`
16+
TargetBranch string `json:"target-branch"`
17+
ReportsDir string `json:"reports-dir"`
18+
CleanupPaths string `json:"cleanup-paths"`
19+
PostGenerateScript string `json:"post-generate-script"`
20+
CommitMessage string `json:"commit-message"`
21+
CleanupWorkers bool `json:"cleanup-workers"`
22+
}
23+
24+
var fanoutFinalizeCmd = &model.ExecutableCommand[fanoutFinalizeFlags]{
25+
Usage: "fanout-finalize",
26+
Short: "Finalize parallel generation by aggregating worker commits into one PR branch",
27+
Long: `Cherry-picks commits from per-target worker branches onto a base branch, aggregates
28+
changelog/report data, removes ephemeral changelog artifacts, squashes to one commit,
29+
force-pushes the PR branch, and creates or updates the PR.`,
30+
Run: runFanoutFinalize,
31+
Flags: []flag.Flag{
32+
flag.StringFlag{
33+
Name: "github-access-token",
34+
Description: "GitHub access token for repository operations",
35+
DefaultValue: os.Getenv("INPUT_GITHUB_ACCESS_TOKEN"),
36+
},
37+
flag.StringFlag{
38+
Name: "base-branch",
39+
Description: "Base branch the workflow is running from",
40+
DefaultValue: os.Getenv("INPUT_BASE_BRANCH"),
41+
},
42+
flag.StringFlag{
43+
Name: "worker-branches",
44+
Description: "Comma/newline-separated worker branches to collect",
45+
DefaultValue: os.Getenv("INPUT_WORKER_BRANCHES"),
46+
Required: true,
47+
},
48+
flag.StringFlag{
49+
Name: "target-branch",
50+
Description: "PR target branch to force-update (if empty, resolve/create automatically)",
51+
DefaultValue: os.Getenv("INPUT_TARGET_BRANCH"),
52+
},
53+
flag.StringFlag{
54+
Name: "reports-dir",
55+
Description: "Directory containing per-target generation reports",
56+
DefaultValue: os.Getenv("INPUT_REPORTS_DIR"),
57+
},
58+
flag.StringFlag{
59+
Name: "cleanup-paths",
60+
Description: "Comma/newline-separated ephemeral paths to remove before final commit",
61+
DefaultValue: os.Getenv("INPUT_CLEANUP_PATHS"),
62+
},
63+
flag.StringFlag{
64+
Name: "post-generate-script",
65+
Description: "Optional script to run after collecting worker commits and before squashing",
66+
DefaultValue: os.Getenv("INPUT_POST_GENERATE_SCRIPT"),
67+
},
68+
flag.StringFlag{
69+
Name: "commit-message",
70+
Description: "Squashed commit message override",
71+
DefaultValue: os.Getenv("INPUT_COMMIT_MESSAGE"),
72+
},
73+
flag.BooleanFlag{
74+
Name: "cleanup-workers",
75+
Description: "Delete worker branches after successful finalization",
76+
DefaultValue: os.Getenv("INPUT_CLEANUP_WORKERS") != "false",
77+
},
78+
},
79+
}
80+
81+
func runFanoutFinalize(ctx context.Context, flags fanoutFinalizeFlags) error {
82+
setEnvIfNotEmpty("INPUT_GITHUB_ACCESS_TOKEN", flags.GithubAccessToken)
83+
setEnvIfNotEmpty("INPUT_BASE_BRANCH", flags.BaseBranch)
84+
setEnvIfNotEmpty("INPUT_WORKER_BRANCHES", flags.WorkerBranches)
85+
setEnvIfNotEmpty("INPUT_TARGET_BRANCH", flags.TargetBranch)
86+
setEnvIfNotEmpty("INPUT_REPORTS_DIR", flags.ReportsDir)
87+
setEnvIfNotEmpty("INPUT_CLEANUP_PATHS", flags.CleanupPaths)
88+
setEnvIfNotEmpty("INPUT_POST_GENERATE_SCRIPT", flags.PostGenerateScript)
89+
setEnvIfNotEmpty("INPUT_COMMIT_MESSAGE", flags.CommitMessage)
90+
setEnvBool("INPUT_CLEANUP_WORKERS", flags.CleanupWorkers)
91+
92+
return actions.FanoutFinalize(ctx, actions.FanoutFinalizeInputs{
93+
BaseBranch: flags.BaseBranch,
94+
WorkerBranches: flags.WorkerBranches,
95+
TargetBranch: flags.TargetBranch,
96+
ReportsDir: flags.ReportsDir,
97+
CleanupPaths: flags.CleanupPaths,
98+
PostGenerateScript: flags.PostGenerateScript,
99+
CommitMessage: flags.CommitMessage,
100+
CleanupWorkers: flags.CleanupWorkers,
101+
})
102+
}

cmd/ci/resolve_branch.go

Lines changed: 0 additions & 55 deletions
This file was deleted.

internal/ci/actions/createOrUpdatePR.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ func CreateOrUpdatePR(ctx context.Context, inputDir, branchName string, dryRun b
111111
return err
112112
}
113113

114+
return createOrUpdatePRFromGenerated(ctx, branchName, output, mergedReport, dryRun)
115+
}
116+
117+
func createOrUpdatePRFromGenerated(ctx context.Context, branchName string, output *prdescription.Output, mergedReport *versioning.MergedVersionReport, dryRun bool) error {
114118
title := output.Title
115119
body := output.Body
116120

0 commit comments

Comments
 (0)