From c59d958d6cf9d0f026da5070801b52c2b038f8b2 Mon Sep 17 00:00:00 2001 From: "Martin Othamar (Agent)" Date: Fri, 7 Aug 2026 07:59:33 +0000 Subject: [PATCH 1/7] feat(releaser): make release recovery resumable Require an exact version and commit for manual recovery. Resume only matching draft releases at the same commit, updating their notes and assets while rejecting published or mismatched releases. --- .github/workflows/release-components.yaml | 14 +- src/tools/releaser/README.md | 9 +- src/tools/releaser/internal/github.go | 74 +++++++ .../releaser/internal/release_trigger.go | 35 ++-- .../internal/release_trigger_internal_test.go | 83 ++++++++ src/tools/releaser/internal/workflow.go | 73 ++++++- src/tools/releaser/internal/workflow_test.go | 185 ++++++++++++++++-- src/tools/releaser/main.go | 5 +- src/tools/releaser/test/e2e/workflow_test.go | 11 ++ 9 files changed, 444 insertions(+), 45 deletions(-) diff --git a/.github/workflows/release-components.yaml b/.github/workflows/release-components.yaml index bd398160693..aa24ab7542f 100644 --- a/.github/workflows/release-components.yaml +++ b/.github/workflows/release-components.yaml @@ -17,6 +17,14 @@ on: options: - app - studioctl + version: + description: Exact release version to recover (for example v1.2.3-preview.4) + required: true + type: string + commit: + description: Full commit SHA containing the promoted changelog section + required: true + type: string permissions: contents: read @@ -50,9 +58,10 @@ jobs: EVENT_NAME: ${{ github.event_name }} REF_NAME: ${{ github.ref_name }} REF_TYPE: ${{ github.ref_type }} - RELEASE_COMMIT: ${{ github.event.after || github.sha }} + RELEASE_COMMIT: ${{ inputs.commit || github.event.after || github.sha }} BEFORE_SHA: ${{ github.event.before || '' }} SELECTED_COMPONENT: ${{ inputs.component || '' }} + SELECTED_VERSION: ${{ inputs.version || '' }} run: | result_file="$RUNNER_TEMP/release-trigger.json" go run . resolve-trigger \ @@ -61,7 +70,8 @@ jobs: -ref-type "$REF_TYPE" \ -commit "$RELEASE_COMMIT" \ -before-sha "$BEFORE_SHA" \ - -selected-component "$SELECTED_COMPONENT" > "$result_file" + -selected-component "$SELECTED_COMPONENT" \ + -selected-version "$SELECTED_VERSION" > "$result_file" component="$(jq -r '.release.component // ""' "$result_file")" base_branch="$(jq -r '.release.baseBranch // ""' "$result_file")" diff --git a/src/tools/releaser/README.md b/src/tools/releaser/README.md index b7351846fef..691d1186822 100644 --- a/src/tools/releaser/README.md +++ b/src/tools/releaser/README.md @@ -98,8 +98,9 @@ prerelease, stabilization, and patch release flows. - The dispatcher intentionally runs on every `main` and `release/**` push and lets `resolve-trigger` no-op when no promotion is present. GitHub path filters inspect at most 300 changed files and could otherwise miss a release in a large push. -- Manual workflow dispatch is a recovery path. Select the component and dispatch from `main` or the matching - `release//vX.Y` branch. +- Manual workflow dispatch is a recovery path. Select the component, enter the exact promoted version and full + commit SHA, and dispatch from `main` or the matching `release//vX.Y` branch. A matching existing draft + release is updated in place so retries can continue after a later publication step fails. - Release publication depends on the unified CI workflow routing the component to its reusable publisher workflow. -- Manual dispatch resolves the version once from the selected commit and branch; publishers never select a newer - version or move their checkout while executing a release plan. +- Manual dispatch validates the exact selected version against the selected commit and branch; publishers never + select a newer version or move their checkout while executing a release plan. diff --git a/src/tools/releaser/internal/github.go b/src/tools/releaser/internal/github.go index 15c6d637dbc..a1c5de95ea9 100644 --- a/src/tools/releaser/internal/github.go +++ b/src/tools/releaser/internal/github.go @@ -3,9 +3,11 @@ package internal import ( "bytes" "context" + "encoding/json" "errors" "fmt" "os/exec" + "strconv" "strings" ) @@ -19,6 +21,10 @@ var ( type GitHubRunner interface { // CreateRelease creates a GitHub release. CreateRelease(ctx context.Context, opts Options) error + // FindRelease returns a release by tag and whether it exists. + FindRelease(ctx context.Context, repository, tag string) (GitHubRelease, bool, error) + // UpdateRelease updates a release and replaces matching draft assets. + UpdateRelease(ctx context.Context, opts Options) error // CreatePR creates a GitHub pull request. CreatePR(ctx context.Context, opts PullRequestOptions) (string, error) // SetWorkdir sets the working directory for gh commands. @@ -50,6 +56,12 @@ type Options struct { FailOnNoCommits bool // Fail if no new commits since last release } +// GitHubRelease is the release state needed to validate a safe retry. +type GitHubRelease struct { + TargetCommitish string `json:"targetCommitish"` + IsDraft bool `json:"isDraft"` +} + // GitHubCLI implements GitHubRunner, using the gh CLI for mutations. type GitHubCLI struct { log Logger @@ -120,6 +132,68 @@ func (g *GitHubCLI) CreateRelease(ctx context.Context, opts Options) error { return g.runWrite(ctx, args...) } +// FindRelease returns a GitHub release by tag. +func (g *GitHubCLI) FindRelease(ctx context.Context, repository, tag string) (GitHubRelease, bool, error) { + args := []string{ + "release", "view", tag, + "--json", "targetCommitish,isDraft", + } + if repository != "" { + args = append(args, "--repo", repository) + } + output, err := g.runRead(ctx, args...) + if err != nil { + if strings.HasSuffix(strings.TrimSpace(err.Error()), ": release not found") { + return GitHubRelease{ + TargetCommitish: "", + IsDraft: false, + }, false, nil + } + return GitHubRelease{}, false, fmt.Errorf("find release %s: %w", tag, err) + } + var release GitHubRelease + if err := json.Unmarshal([]byte(output), &release); err != nil { + return GitHubRelease{}, false, fmt.Errorf("decode release %s: %w", tag, err) + } + return release, true, nil +} + +// UpdateRelease updates an existing draft release and replaces matching assets. +func (g *GitHubCLI) UpdateRelease(ctx context.Context, opts Options) error { + args := []string{"release", "edit", opts.Tag} + if opts.Repository != "" { + args = append(args, "--repo", opts.Repository) + } + if opts.Title != "" { + args = append(args, "--title", opts.Title) + } + if opts.NotesFile != "" { + args = append(args, "--notes-file", opts.NotesFile) + } + if opts.Target != "" { + args = append(args, "--target", opts.Target) + } + args = append( + args, + "--draft="+strconv.FormatBool(opts.Draft), + "--prerelease="+strconv.FormatBool(opts.Prerelease), + ) + if err := g.runWrite(ctx, args...); err != nil { + return err + } + if len(opts.Assets) == 0 { + return nil + } + + uploadArgs := []string{"release", "upload", opts.Tag} + if opts.Repository != "" { + uploadArgs = append(uploadArgs, "--repo", opts.Repository) + } + uploadArgs = append(uploadArgs, "--clobber") + uploadArgs = append(uploadArgs, opts.Assets...) + return g.runWrite(ctx, uploadArgs...) +} + // CreatePR creates a GitHub pull request using the gh CLI. func (g *GitHubCLI) CreatePR(ctx context.Context, opts PullRequestOptions) (string, error) { args := []string{"pr", "create"} diff --git a/src/tools/releaser/internal/release_trigger.go b/src/tools/releaser/internal/release_trigger.go index e4acf9aa41e..7d46bcf752b 100644 --- a/src/tools/releaser/internal/release_trigger.go +++ b/src/tools/releaser/internal/release_trigger.go @@ -9,12 +9,14 @@ import ( ) var ( - errReleaseTriggerEvent = errors.New("unsupported release trigger event") - errReleaseTriggerBranchRequired = errors.New("release trigger must run from a branch") - errReleaseTriggerRefRequired = errors.New("release trigger ref is required") - errReleaseTriggerSHARequired = errors.New("release trigger commit SHA is required") - errReleaseTriggerBeforeRequired = errors.New("release trigger before SHA is required") - errReleaseTriggerPromotionCount = errors.New( + errReleaseTriggerEvent = errors.New("unsupported release trigger event") + errReleaseTriggerBranchRequired = errors.New("release trigger must run from a branch") + errReleaseTriggerRefRequired = errors.New("release trigger ref is required") + errReleaseTriggerSHARequired = errors.New("release trigger commit SHA is required") + errReleaseTriggerBeforeRequired = errors.New("release trigger before SHA is required") + errReleaseTriggerVersionRequired = errors.New("manual release version is required") + errReleaseTriggerVersionMissing = errors.New("manual release version is not present in the changelog") + errReleaseTriggerPromotionCount = errors.New( "push contains release promotions for multiple components", ) ) @@ -27,6 +29,7 @@ type ReleaseTriggerRequest struct { Commit string BeforeSHA string SelectedComponent string + SelectedVersion string } // ReleasePlan is the immutable release context emitted to CI. @@ -77,6 +80,9 @@ func resolveReleaseTriggerWithDeps( switch req.EventName { case "workflow_dispatch": + if req.SelectedVersion == "" { + return ReleaseTriggerResult{}, errReleaseTriggerVersionRequired + } component, err := validateReleaseTriggerComponent(req.SelectedComponent, req.RefName) if err != nil { return ReleaseTriggerResult{}, err @@ -84,15 +90,22 @@ func resolveReleaseTriggerWithDeps( if git == nil { return ReleaseTriggerResult{}, errGitRequired } - cl, err := loadChangelogAtRef(ctx, git, req.Commit, component.ChangelogPath) + commit, err := git.Run(ctx, "rev-parse", req.Commit+"^{commit}") if err != nil { - return ReleaseTriggerResult{}, fmt.Errorf("load %s changelog at %s: %w", component.Name, req.Commit, err) + return ReleaseTriggerResult{}, fmt.Errorf("resolve manual release commit: %w", err) } - resolvedVersion, err := resolveWorkflowVersionFromChangelog(component, req.RefName, cl) + cl, err := loadChangelogAtRef(ctx, git, commit, component.ChangelogPath) if err != nil { - return ReleaseTriggerResult{}, fmt.Errorf("resolve manual release version: %w", err) + return ReleaseTriggerResult{}, fmt.Errorf("load %s changelog at %s: %w", component.Name, commit, err) + } + selectedVersion := normalizeVersionPrefix(req.SelectedVersion) + if err := validateWorkflowReleasePlan(component, req.RefName, selectedVersion); err != nil { + return ReleaseTriggerResult{}, fmt.Errorf("validate manual release plan: %w", err) + } + if !cl.HasVersion(selectedVersion) { + return ReleaseTriggerResult{}, fmt.Errorf("%w: %s", errReleaseTriggerVersionMissing, selectedVersion) } - return releaseTriggerResult(component.Name, req.RefName, req.Commit, resolvedVersion), nil + return releaseTriggerResult(component.Name, req.RefName, commit, selectedVersion), nil case "push": return resolvePushReleaseTrigger(ctx, req, git) default: diff --git a/src/tools/releaser/internal/release_trigger_internal_test.go b/src/tools/releaser/internal/release_trigger_internal_test.go index 6280ef980d5..739ae7a76ac 100644 --- a/src/tools/releaser/internal/release_trigger_internal_test.go +++ b/src/tools/releaser/internal/release_trigger_internal_test.go @@ -239,10 +239,15 @@ func TestResolveReleaseTriggerManual(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() + selectedVersion := tc.version + if selectedVersion == "" { + selectedVersion = triggerVersion + } git := fakeReleaseTriggerGit{} if tc.version != "" { component := components[tc.component] git.outputs = map[string]string{ + "rev-parse " + triggerHeadSHA + "^{commit}": triggerHeadSHA, "show " + triggerHeadSHA + ":" + component.ChangelogPath: triggerReleasedChangelog(tc.version), } } @@ -252,6 +257,7 @@ func TestResolveReleaseTriggerManual(t *testing.T) { RefType: tc.refType, Commit: triggerHeadSHA, SelectedComponent: tc.component, + SelectedVersion: selectedVersion, }, git) if tc.wantErr != nil { if !errors.Is(err, tc.wantErr) { @@ -270,6 +276,83 @@ func TestResolveReleaseTriggerManual(t *testing.T) { } } +func TestResolveReleaseTriggerManualRequiresExactVersion(t *testing.T) { + t.Parallel() + + _, err := resolveReleaseTriggerWithDeps(t.Context(), ReleaseTriggerRequest{ + EventName: "workflow_dispatch", + RefName: "main", + RefType: "branch", + Commit: triggerHeadSHA, + SelectedComponent: "studioctl", + }, fakeReleaseTriggerGit{}) + if !errors.Is(err, errReleaseTriggerVersionRequired) { + t.Fatalf("resolveReleaseTriggerWithDeps() error = %v, want %v", err, errReleaseTriggerVersionRequired) + } +} + +func TestResolveReleaseTriggerManualCanRecoverOlderVersion(t *testing.T) { + t.Parallel() + + const selectedVersion = "v1.3.0-preview.1" + appPath := components["app"].ChangelogPath + git := fakeReleaseTriggerGit{outputs: map[string]string{ + "rev-parse " + triggerHeadSHA + "^{commit}": triggerHeadSHA, + "show " + triggerHeadSHA + ":" + appPath: `# Changelog + +## [Unreleased] + +## [v1.3.0-preview.2] - 2026-08-07 + +### Added + +- Newer release + +## [v1.3.0-preview.1] - 2026-08-06 + +### Added + +- Failed release +`, + }} + got, err := resolveReleaseTriggerWithDeps(t.Context(), ReleaseTriggerRequest{ + EventName: "workflow_dispatch", + RefName: "main", + RefType: "branch", + Commit: triggerHeadSHA, + SelectedComponent: "app", + SelectedVersion: selectedVersion, + }, git) + if err != nil { + t.Fatalf("resolveReleaseTriggerWithDeps() error = %v", err) + } + want := releaseTriggerResult("app", "main", triggerHeadSHA, selectedVersion) + if !reflect.DeepEqual(got, want) { + t.Fatalf("resolveReleaseTriggerWithDeps() = %+v, want %+v", got, want) + } +} + +func TestResolveReleaseTriggerManualRejectsMissingVersion(t *testing.T) { + t.Parallel() + + appPath := components["app"].ChangelogPath + git := fakeReleaseTriggerGit{outputs: map[string]string{ + "rev-parse " + triggerHeadSHA + "^{commit}": triggerHeadSHA, + "show " + triggerHeadSHA + ":" + appPath: triggerReleasedChangelog("v1.3.0-preview.2"), + }} + _, err := resolveReleaseTriggerWithDeps(t.Context(), ReleaseTriggerRequest{ + EventName: "workflow_dispatch", + RefName: "main", + RefType: "branch", + Commit: triggerHeadSHA, + SelectedComponent: "app", + SelectedVersion: "v1.3.0-preview.1", + }, git) + if !errors.Is(err, errReleaseTriggerVersionMissing) { + t.Fatalf("resolveReleaseTriggerWithDeps() error = %v, want %v", err, errReleaseTriggerVersionMissing) + } +} + func TestResolveReleaseTriggerPush(t *testing.T) { t.Parallel() diff --git a/src/tools/releaser/internal/workflow.go b/src/tools/releaser/internal/workflow.go index e1bb5fdf876..5c0053707cd 100644 --- a/src/tools/releaser/internal/workflow.go +++ b/src/tools/releaser/internal/workflow.go @@ -17,11 +17,13 @@ import ( // Workflow errors. var ( - ErrChangelogMissing = errors.New("changelog version section not found") - ErrBuildFailed = errors.New("build failed") - ErrReleaseBranchMissing = errors.New("release branch does not exist for stable release") - ErrWrongReleaseBranch = errors.New("release must run from its canonical branch") - errReleaseTargetMissing = errors.New("release target commit is empty") + ErrChangelogMissing = errors.New("changelog version section not found") + ErrBuildFailed = errors.New("build failed") + ErrReleaseBranchMissing = errors.New("release branch does not exist for stable release") + ErrReleasePublished = errors.New("release already exists and is not a draft") + ErrReleaseTargetMismatch = errors.New("existing draft targets a different commit") + ErrWrongReleaseBranch = errors.New("release must run from its canonical branch") + errReleaseTargetMissing = errors.New("release target commit is empty") ) // WorkflowConfig configures the release workflow. @@ -50,6 +52,7 @@ type Workflow struct { artifacts []string topology RepositoryTopology config WorkflowConfig + resumeDraft bool } // NewWorkflow creates a new Workflow instance. @@ -117,6 +120,7 @@ func NewWorkflow( parsedChangelog: nil, artifacts: nil, topology: topology, + resumeDraft: false, }, nil } @@ -193,7 +197,7 @@ func (w *Workflow) Run(ctx context.Context) error { return err } - if err := w.validateTagNotExists(ctx); err != nil { + if err := w.prepareReleaseState(ctx); err != nil { return err } @@ -250,10 +254,20 @@ func (w *Workflow) parseTag() error { return nil } -func (w *Workflow) validateTagNotExists(ctx context.Context) error { - w.log.Step("Checking tag does not exist") +func (w *Workflow) prepareReleaseState(ctx context.Context) error { + w.log.Step("Checking release state") tagFull := w.tag.Full() + if !w.config.DryRun { + resumed, err := w.prepareExistingDraft(ctx, tagFull) + if err != nil { + return err + } + if resumed { + return nil + } + } + exists, err := w.git.TagExists(ctx, w.topology.SourceRemote, tagFull) if err != nil { return fmt.Errorf("check tag exists: %w", err) @@ -272,6 +286,39 @@ func (w *Workflow) validateTagNotExists(ctx context.Context) error { return nil } +func (w *Workflow) prepareExistingDraft(ctx context.Context, tag string) (bool, error) { + existingRelease, found, err := w.gh.FindRelease( + ctx, + w.topology.BaseRepository.NameWithOwner, + tag, + ) + if err != nil { + return false, fmt.Errorf("check existing release: %w", err) + } + if !found { + return false, nil + } + if !existingRelease.IsDraft { + return false, fmt.Errorf("%w: %s", ErrReleasePublished, tag) + } + target, err := w.determineReleaseTarget(ctx) + if err != nil { + return false, err + } + if existingRelease.TargetCommitish != target { + return false, fmt.Errorf( + "%w: %s targets %s, expected %s", + ErrReleaseTargetMismatch, + tag, + existingRelease.TargetCommitish, + target, + ) + } + w.resumeDraft = true + w.log.Success("Existing draft matches release plan; publication will resume") + return true, nil +} + // enforceRefPolicy validates the planned ref against release type rules. func (w *Workflow) enforceRefPolicy(ctx context.Context) error { w.log.Step("Enforcing ref policy") @@ -501,7 +548,17 @@ func (w *Workflow) createGitHubRelease(ctx context.Context) error { // gh CLI needs to run from repo root w.gh.SetWorkdir(w.config.RepoRoot) + return w.publishGitHubRelease(ctx, opts) +} +func (w *Workflow) publishGitHubRelease(ctx context.Context, opts Options) error { + if w.resumeDraft { + if err := w.gh.UpdateRelease(ctx, opts); err != nil { + return fmt.Errorf("update draft release: %w", err) + } + w.log.Success("GitHub draft release updated") + return nil + } if err := w.gh.CreateRelease(ctx, opts); err != nil { return fmt.Errorf("create release: %w", err) } diff --git a/src/tools/releaser/internal/workflow_test.go b/src/tools/releaser/internal/workflow_test.go index 76b9755bb33..c846d21eb9b 100644 --- a/src/tools/releaser/internal/workflow_test.go +++ b/src/tools/releaser/internal/workflow_test.go @@ -104,6 +104,132 @@ func TestWorkflow_Run_DryRunAllowsExistingTag(t *testing.T) { } } +func TestWorkflow_Run_ResumesMatchingDraft(t *testing.T) { + t.Parallel() + + changelogPath := writeChangelog(t, `# Changelog + +## [Unreleased] + +## [v1.2.3-preview.1] - 2025-01-01 + +### Added + +- Test entry +`) + builder := &fakeBuilder{} + gh := &fakeGH{existingRelease: &internal.GitHubRelease{ + TargetCommitish: fakeHeadCommit, + IsDraft: true, + }} + workflow, err := internal.NewWorkflow(t.Context(), internal.WorkflowConfig{ + Component: "studioctl", + Version: "v1.2.3-preview.1", + ChangelogPath: changelogPath, + OutputDir: t.TempDir(), + RepoRoot: os.TempDir(), + Draft: true, + }, &fakeGit{currentBranch: "main"}, gh, builder, internal.NopLogger{}) + if err != nil { + t.Fatalf("NewWorkflow() error: %v", err) + } + if err := workflow.Run(t.Context()); err != nil { + t.Fatalf("workflow.Run() error: %v", err) + } + if !builder.called || !gh.updated { + t.Fatalf("resume called builder=%v updated=%v, want both true", builder.called, gh.updated) + } +} + +func TestWorkflow_Run_RejectsUnsafeExistingRelease(t *testing.T) { + t.Parallel() + + tests := []struct { + wantErr error + name string + release internal.GitHubRelease + }{ + { + name: "published release", + release: internal.GitHubRelease{ + TargetCommitish: fakeHeadCommit, + IsDraft: false, + }, + wantErr: internal.ErrReleasePublished, + }, + { + name: "draft at another commit", + release: internal.GitHubRelease{ + TargetCommitish: "another-commit", + IsDraft: true, + }, + wantErr: internal.ErrReleaseTargetMismatch, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + builder := &fakeBuilder{} + gh := &fakeGH{existingRelease: &tc.release} + workflow, err := internal.NewWorkflow(t.Context(), internal.WorkflowConfig{ + Component: "studioctl", + Version: "v1.2.3-preview.1", + OutputDir: t.TempDir(), + RepoRoot: os.TempDir(), + Draft: true, + }, &fakeGit{currentBranch: "main"}, gh, builder, internal.NopLogger{}) + if err != nil { + t.Fatalf("NewWorkflow() error: %v", err) + } + err = workflow.Run(t.Context()) + if !errors.Is(err, tc.wantErr) { + t.Fatalf("workflow.Run() error = %v, want %v", err, tc.wantErr) + } + if builder.called || gh.called { + t.Fatal("unsafe retry reached build or release mutation") + } + }) + } +} + +func TestGitHubCLI_UpdateReleaseReplacesDraftAssets(t *testing.T) { + t.Parallel() + + commands := make([]string, 0, 2) + logger := &commandHookLogger{onCommand: func(command string, args []string) { + commands = append(commands, command+" "+strings.Join(args, " ")) + }} + gh := internal.NewGitHubCLI( + internal.WithGHDryRun(true), + internal.WithGHLogger(logger), + ) + err := gh.UpdateRelease(t.Context(), internal.Options{ + Tag: "studioctl/v1.2.3-preview.1", + Title: "studioctl v1.2.3-preview.1", + NotesFile: "/tmp/notes.md", + Target: fakeHeadCommit, + Repository: "Altinn/altinn-studio", + Assets: []string{"/tmp/studioctl.tar.gz"}, + Draft: true, + Prerelease: true, + FailOnNoCommits: false, + }) + if err != nil { + t.Fatalf("UpdateRelease() error: %v", err) + } + if len(commands) != 2 { + t.Fatalf("commands = %v, want edit and upload", commands) + } + if !strings.Contains(commands[0], "release edit studioctl/v1.2.3-preview.1") || + !strings.Contains(commands[0], "--target "+fakeHeadCommit) { + t.Fatalf("edit command = %q", commands[0]) + } + if !strings.Contains(commands[1], "release upload studioctl/v1.2.3-preview.1") || + !strings.Contains(commands[1], "--clobber /tmp/studioctl.tar.gz") { + t.Fatalf("upload command = %q", commands[1]) + } +} + func TestWorkflow_Run_PreviewMustBeOnMain(t *testing.T) { t.Parallel() @@ -612,41 +738,44 @@ func (g *fakeGit) PushRemote( } type fakeGH struct { - tag string - target string - prBase string + existingRelease *internal.GitHubRelease + canonicalRepositoryName string + pushRepositoryName string prTitle string - prBody string + tag string prLabel string prHead string prRepository string releaseRepository string + prBase string canonicalRepositoryURL string - canonicalRepositoryName string - pushRepositoryName string + prBody string + target string assets []string assetCount int prerelease bool hasReleaseNotes bool called bool + updated bool prCreated bool } func (g *fakeGH) CreateRelease(_ context.Context, opts internal.Options) error { - g.called = true - g.tag = opts.Tag - g.target = opts.Target - g.releaseRepository = opts.Repository - g.prerelease = opts.Prerelease - g.assetCount = len(opts.Assets) - g.assets = append([]string(nil), opts.Assets...) - for _, asset := range opts.Assets { - if filepath.Base(asset) == "release-notes.md" { - g.hasReleaseNotes = true - break - } + return g.recordRelease(opts, false) +} + +func (g *fakeGH) FindRelease( + _ context.Context, + _, _ string, +) (internal.GitHubRelease, bool, error) { + if g.existingRelease == nil { + return internal.GitHubRelease{}, false, nil } - return nil + return *g.existingRelease, true, nil +} + +func (g *fakeGH) UpdateRelease(_ context.Context, opts internal.Options) error { + return g.recordRelease(opts, true) } func (g *fakeGH) CreatePR(_ context.Context, opts internal.PullRequestOptions) (string, error) { @@ -680,6 +809,24 @@ func (g *fakeGH) Repository( return repository, parent, nil } +func (g *fakeGH) recordRelease(opts internal.Options, updated bool) error { + g.called = true + g.updated = updated + g.tag = opts.Tag + g.target = opts.Target + g.releaseRepository = opts.Repository + g.prerelease = opts.Prerelease + g.assetCount = len(opts.Assets) + g.assets = append([]string(nil), opts.Assets...) + for _, asset := range opts.Assets { + if filepath.Base(asset) == "release-notes.md" { + g.hasReleaseNotes = true + break + } + } + return nil +} + type fakeBuilder struct { called bool } diff --git a/src/tools/releaser/main.go b/src/tools/releaser/main.go index 9d76facf342..28541b842b9 100644 --- a/src/tools/releaser/main.go +++ b/src/tools/releaser/main.go @@ -207,13 +207,15 @@ func runResolveTrigger(args []string) error { commit := fs.String("commit", "", "Commit SHA for the event") beforeSHA := fs.String("before-sha", "", "Commit before a push event") selectedComponent := fs.String("selected-component", "", "Component selected for manual recovery") + selectedVersion := fs.String("selected-version", "", "Exact version selected for manual recovery") fs.Usage = func() { fmt.Print(`Usage: releaser resolve-trigger [options] Resolves a trusted canonical repository event into a component release context. Push events are inspected with Git for a registered component changelog promotion between the before and head commits, then validated against the component's branch -policy. Manual dispatches validate the selected component and branch for recovery. +policy. Manual dispatches validate an exact component, version, commit, and branch +for recovery. The result is emitted as JSON for CI orchestration. @@ -232,6 +234,7 @@ Options: Commit: *commit, BeforeSHA: *beforeSHA, SelectedComponent: *selectedComponent, + SelectedVersion: *selectedVersion, }) if err != nil { return fmt.Errorf("resolve trigger: %w", err) diff --git a/src/tools/releaser/test/e2e/workflow_test.go b/src/tools/releaser/test/e2e/workflow_test.go index edb07cff937..78f1db15c9f 100644 --- a/src/tools/releaser/test/e2e/workflow_test.go +++ b/src/tools/releaser/test/e2e/workflow_test.go @@ -943,6 +943,17 @@ func (g *fakeGH) CreateRelease(_ context.Context, opts internal.Options) error { return nil } +func (g *fakeGH) FindRelease( + _ context.Context, + _, _ string, +) (internal.GitHubRelease, bool, error) { + return internal.GitHubRelease{}, false, nil +} + +func (g *fakeGH) UpdateRelease(ctx context.Context, opts internal.Options) error { + return g.CreateRelease(ctx, opts) +} + func (g *fakeGH) CreatePR(_ context.Context, opts internal.PullRequestOptions) (string, error) { g.prCreated = true g.prBase = opts.Base From b3e5baa29f9656c4e24c7bb7b283c07bd9ff7294 Mon Sep 17 00:00:00 2001 From: "Martin Othamar (Agent)" Date: Fri, 7 Aug 2026 08:22:49 +0000 Subject: [PATCH 2/7] fix(releaser): require exact recovery commits --- .../releaser/internal/release_trigger.go | 21 ++++++++++++++-- .../internal/release_trigger_internal_test.go | 24 +++++++++++++++++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/tools/releaser/internal/release_trigger.go b/src/tools/releaser/internal/release_trigger.go index 7d46bcf752b..5899309fb77 100644 --- a/src/tools/releaser/internal/release_trigger.go +++ b/src/tools/releaser/internal/release_trigger.go @@ -16,6 +16,7 @@ var ( errReleaseTriggerBeforeRequired = errors.New("release trigger before SHA is required") errReleaseTriggerVersionRequired = errors.New("manual release version is required") errReleaseTriggerVersionMissing = errors.New("manual release version is not present in the changelog") + errReleaseTriggerCommitExact = errors.New("manual release commit must be a full commit SHA") errReleaseTriggerPromotionCount = errors.New( "push contains release promotions for multiple components", ) @@ -90,9 +91,9 @@ func resolveReleaseTriggerWithDeps( if git == nil { return ReleaseTriggerResult{}, errGitRequired } - commit, err := git.Run(ctx, "rev-parse", req.Commit+"^{commit}") + commit, err := resolveExactManualCommit(ctx, git, req.Commit) if err != nil { - return ReleaseTriggerResult{}, fmt.Errorf("resolve manual release commit: %w", err) + return ReleaseTriggerResult{}, err } cl, err := loadChangelogAtRef(ctx, git, commit, component.ChangelogPath) if err != nil { @@ -113,6 +114,22 @@ func resolveReleaseTriggerWithDeps( } } +func resolveExactManualCommit(ctx context.Context, git gitReader, requestedCommit string) (string, error) { + commit, err := git.Run(ctx, "rev-parse", requestedCommit+"^{commit}") + if err != nil { + return "", fmt.Errorf("resolve manual release commit: %w", err) + } + if !strings.EqualFold(requestedCommit, commit) { + return "", fmt.Errorf( + "%w: got %s, resolved to %s", + errReleaseTriggerCommitExact, + requestedCommit, + commit, + ) + } + return commit, nil +} + func resolvePushReleaseTrigger( ctx context.Context, req ReleaseTriggerRequest, diff --git a/src/tools/releaser/internal/release_trigger_internal_test.go b/src/tools/releaser/internal/release_trigger_internal_test.go index 739ae7a76ac..40ff4ae5781 100644 --- a/src/tools/releaser/internal/release_trigger_internal_test.go +++ b/src/tools/releaser/internal/release_trigger_internal_test.go @@ -10,8 +10,8 @@ import ( ) const ( - triggerBeforeSHA = "abcdef0123456789" - triggerHeadSHA = "0123456789abcdef" + triggerBeforeSHA = "abcdef0123456789abcdef0123456789abcdef01" + triggerHeadSHA = "0123456789abcdef0123456789abcdef01234567" triggerVersion = "v1.0.0" triggerBaseChangelog = `# Changelog @@ -291,6 +291,26 @@ func TestResolveReleaseTriggerManualRequiresExactVersion(t *testing.T) { } } +func TestResolveReleaseTriggerManualRequiresFullCommitSHA(t *testing.T) { + t.Parallel() + + const abbreviatedCommit = "01234567" + git := fakeReleaseTriggerGit{outputs: map[string]string{ + "rev-parse " + abbreviatedCommit + "^{commit}": triggerHeadSHA, + }} + _, err := resolveReleaseTriggerWithDeps(t.Context(), ReleaseTriggerRequest{ + EventName: "workflow_dispatch", + RefName: "main", + RefType: "branch", + Commit: abbreviatedCommit, + SelectedComponent: "studioctl", + SelectedVersion: "v0.1.0-preview.1", + }, git) + if !errors.Is(err, errReleaseTriggerCommitExact) { + t.Fatalf("resolveReleaseTriggerWithDeps() error = %v, want %v", err, errReleaseTriggerCommitExact) + } +} + func TestResolveReleaseTriggerManualCanRecoverOlderVersion(t *testing.T) { t.Parallel() From 314642d7674f0287486608c57133ed61d3bd2857 Mon Sep 17 00:00:00 2001 From: "Martin Othamar (Agent)" Date: Fri, 7 Aug 2026 08:28:38 +0000 Subject: [PATCH 3/7] fix(releaser): verify release tag target --- src/tools/releaser/internal/git.go | 27 ++++++++++ src/tools/releaser/internal/preflight_test.go | 11 ++++ src/tools/releaser/internal/workflow.go | 26 ++++++++++ src/tools/releaser/internal/workflow_test.go | 52 +++++++++++++++++++ 4 files changed, 116 insertions(+) diff --git a/src/tools/releaser/internal/git.go b/src/tools/releaser/internal/git.go index 6a1ce690783..59f3c68fc1b 100644 --- a/src/tools/releaser/internal/git.go +++ b/src/tools/releaser/internal/git.go @@ -29,6 +29,8 @@ type GitRemote struct { type GitRunner interface { // TagExists checks if a tag exists in the repository. TagExists(ctx context.Context, remote, tag string) (bool, error) + // RemoteTagCommit returns a remote tag's commit target and whether it exists. + RemoteTagCommit(ctx context.Context, remote, tag string) (string, bool, error) // CurrentBranch returns the current branch name. CurrentBranch(ctx context.Context) (string, error) // RemoteBranchExists checks if a branch exists on the authoritative source remote. @@ -103,6 +105,31 @@ func (g *GitCLI) TagExists(ctx context.Context, remote, tag string) (bool, error return remoteRefExists(exitCode, remote) } +// RemoteTagCommit returns the commit targeted by a lightweight or annotated remote tag. +func (g *GitCLI) RemoteTagCommit(ctx context.Context, remote, tag string) (string, bool, error) { + ref := "refs/tags/" + tag + output, err := g.Run(ctx, "ls-remote", "--tags", remote, ref, ref+"^{}") + if err != nil { + return "", false, err + } + if output == "" { + return "", false, nil + } + + target := "" + for line := range strings.SplitSeq(output, "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + return "", false, fmt.Errorf("%w: malformed remote tag output for %s", ErrGitCommandFailed, tag) + } + target = fields[0] + if fields[1] == ref+"^{}" { + return target, true, nil + } + } + return target, true, nil +} + // CurrentBranch returns the current branch name. func (g *GitCLI) CurrentBranch(ctx context.Context) (string, error) { return g.run(ctx, "rev-parse", "--abbrev-ref", "HEAD") diff --git a/src/tools/releaser/internal/preflight_test.go b/src/tools/releaser/internal/preflight_test.go index 57f0ed0ed95..da701ff248e 100644 --- a/src/tools/releaser/internal/preflight_test.go +++ b/src/tools/releaser/internal/preflight_test.go @@ -1714,6 +1714,17 @@ func TestGitCLI_TagExistsChecksRequestedRemote(t *testing.T) { if !exists { t.Fatalf("TagExists() = false, want requested remote tag %s", tag) } + target, found, err := git.RemoteTagCommit(t.Context(), "tag-source", tag) + if err != nil { + t.Fatalf("RemoteTagCommit() error = %v", err) + } + if !found { + t.Fatalf("RemoteTagCommit() found = false, want requested remote tag %s", tag) + } + wantTarget := remoteBranchHead(t, repo, "tag-source", "main") + if target != wantTarget { + t.Fatalf("RemoteTagCommit() = %s, want %s", target, wantTarget) + } } func TestGitCLI_RemoteRefChecksFailClosed(t *testing.T) { diff --git a/src/tools/releaser/internal/workflow.go b/src/tools/releaser/internal/workflow.go index 5c0053707cd..606c73109a4 100644 --- a/src/tools/releaser/internal/workflow.go +++ b/src/tools/releaser/internal/workflow.go @@ -22,6 +22,7 @@ var ( ErrReleaseBranchMissing = errors.New("release branch does not exist for stable release") ErrReleasePublished = errors.New("release already exists and is not a draft") ErrReleaseTargetMismatch = errors.New("existing draft targets a different commit") + ErrReleaseTagMismatch = errors.New("release tag targets a different commit") ErrWrongReleaseBranch = errors.New("release must run from its canonical branch") errReleaseTargetMissing = errors.New("release target commit is empty") ) @@ -548,9 +549,34 @@ func (w *Workflow) createGitHubRelease(ctx context.Context) error { // gh CLI needs to run from repo root w.gh.SetWorkdir(w.config.RepoRoot) + if err := w.validateRemoteTagTarget(ctx, target); err != nil { + return err + } return w.publishGitHubRelease(ctx, opts) } +func (w *Workflow) validateRemoteTagTarget(ctx context.Context, target string) error { + tag := w.tag.Full() + remoteTarget, exists, err := w.git.RemoteTagCommit(ctx, w.topology.SourceRemote, tag) + if err != nil { + return fmt.Errorf("resolve remote release tag: %w", err) + } + if !exists { + return nil + } + if remoteTarget != target { + return fmt.Errorf( + "%w: %s targets %s, expected %s", + ErrReleaseTagMismatch, + tag, + remoteTarget, + target, + ) + } + w.log.Success("Existing release tag matches release plan") + return nil +} + func (w *Workflow) publishGitHubRelease(ctx context.Context, opts Options) error { if w.resumeDraft { if err := w.gh.UpdateRelease(ctx, opts); err != nil { diff --git a/src/tools/releaser/internal/workflow_test.go b/src/tools/releaser/internal/workflow_test.go index c846d21eb9b..b5f9c0489d2 100644 --- a/src/tools/releaser/internal/workflow_test.go +++ b/src/tools/releaser/internal/workflow_test.go @@ -192,6 +192,52 @@ func TestWorkflow_Run_RejectsUnsafeExistingRelease(t *testing.T) { } } +func TestWorkflow_Run_RejectsDraftWithMovedTag(t *testing.T) { + t.Parallel() + + changelogPath := writeChangelog(t, `# Changelog + +## [Unreleased] + +## [v1.2.3-preview.1] - 2025-01-01 + +### Added + +- Test entry +`) + builder := &fakeBuilder{} + gh := &fakeGH{existingRelease: &internal.GitHubRelease{ + TargetCommitish: fakeHeadCommit, + IsDraft: true, + }} + git := &fakeGit{ + currentBranch: "main", + remoteTagCommit: "another-commit", + remoteTagExists: true, + } + workflow, err := internal.NewWorkflow(t.Context(), internal.WorkflowConfig{ + Component: "studioctl", + Version: "v1.2.3-preview.1", + ChangelogPath: changelogPath, + OutputDir: t.TempDir(), + RepoRoot: os.TempDir(), + Draft: true, + }, git, gh, builder, internal.NopLogger{}) + if err != nil { + t.Fatalf("NewWorkflow() error: %v", err) + } + err = workflow.Run(t.Context()) + if !errors.Is(err, internal.ErrReleaseTagMismatch) { + t.Fatalf("workflow.Run() error = %v, want %v", err, internal.ErrReleaseTagMismatch) + } + if !builder.called { + t.Fatal("expected tag target to be rechecked after the build") + } + if gh.updated { + t.Fatal("moved tag reached draft update") + } +} + func TestGitHubCLI_UpdateReleaseReplacesDraftAssets(t *testing.T) { t.Parallel() @@ -676,9 +722,11 @@ func TestNewWorkflow_OutputDirSafety_RejectsSymlinkEscape(t *testing.T) { type fakeGit struct { currentBranch string headCommit string + remoteTagCommit string remoteBranchExistsResponses []bool remoteBranchExistsCallCount int tagExists bool + remoteTagExists bool remoteBranchExists bool workingTreeClean bool } @@ -687,6 +735,10 @@ func (g *fakeGit) TagExists(_ context.Context, _, _ string) (bool, error) { return g.tagExists, nil } +func (g *fakeGit) RemoteTagCommit(_ context.Context, _, _ string) (string, bool, error) { + return g.remoteTagCommit, g.remoteTagExists, nil +} + func (g *fakeGit) CurrentBranch(_ context.Context) (string, error) { if g.currentBranch == "" { return "main", nil From 03e6b27a555662867e53a1781b0c05857897dd33 Mon Sep 17 00:00:00 2001 From: "Martin Othamar (Agent)" Date: Fri, 7 Aug 2026 08:06:40 +0000 Subject: [PATCH 4/7] refactor(releaser): centralize publication policy --- .github/workflows/release-components.yaml | 49 ++++---------- src/tools/releaser/README.md | 11 ++-- src/tools/releaser/internal/component.go | 40 ++++++------ src/tools/releaser/internal/release_policy.go | 54 ++++++++++++++++ .../internal/release_policy_internal_test.go | 64 +++++++++++++++++++ .../releaser/internal/release_trigger.go | 50 +++++++++++---- .../internal/release_trigger_internal_test.go | 62 +++++++++++++----- 7 files changed, 242 insertions(+), 88 deletions(-) create mode 100644 src/tools/releaser/internal/release_policy.go create mode 100644 src/tools/releaser/internal/release_policy_internal_test.go diff --git a/.github/workflows/release-components.yaml b/.github/workflows/release-components.yaml index aa24ab7542f..21fe32a53a8 100644 --- a/.github/workflows/release-components.yaml +++ b/.github/workflows/release-components.yaml @@ -35,6 +35,8 @@ jobs: runs-on: ubuntu-latest outputs: component: ${{ steps.resolve.outputs.component }} + publisher: ${{ steps.resolve.outputs.publisher }} + environment: ${{ steps.resolve.outputs.environment }} base-branch: ${{ steps.resolve.outputs.base-branch }} commit: ${{ steps.resolve.outputs.commit }} release-version: ${{ steps.resolve.outputs.release-version }} @@ -74,12 +76,16 @@ jobs: -selected-version "$SELECTED_VERSION" > "$result_file" component="$(jq -r '.release.component // ""' "$result_file")" + publisher="$(jq -r '.release.publisher // ""' "$result_file")" + environment="$(jq -r '.release.environment // ""' "$result_file")" base_branch="$(jq -r '.release.baseBranch // ""' "$result_file")" commit="$(jq -r '.release.commit // ""' "$result_file")" release_version="$(jq -r '.release.releaseVersion // ""' "$result_file")" { echo "component=$component" + echo "publisher=$publisher" + echo "environment=$environment" echo "base-branch=$base_branch" echo "commit=$commit" echo "release-version=$release_version" @@ -92,50 +98,24 @@ jobs: fi working-directory: src/tools/releaser - resolve-environment: - name: Resolve release environment - if: needs.resolve-trigger.outputs.component != '' - needs: resolve-trigger - runs-on: ubuntu-latest - outputs: - environment: ${{ steps.context.outputs.environment }} - steps: - - name: Resolve environment - id: context - env: - RELEASE_VERSION: ${{ needs.resolve-trigger.outputs.release-version }} - run: | - environment="prod" - if [[ "$RELEASE_VERSION" == *"-preview."* ]]; then - environment="dev" - elif [[ "$RELEASE_VERSION" == *"-rc."* ]]; then - environment="staging" - fi - - echo "environment=$environment" >> "$GITHUB_OUTPUT" - release-app: name: Release app - if: needs.resolve-trigger.outputs.component == 'app' - needs: - - resolve-trigger - - resolve-environment + if: needs.resolve-trigger.outputs.publisher == 'app' + needs: resolve-trigger permissions: contents: write id-token: write uses: ./.github/workflows/release-app.yaml with: base-branch: ${{ needs.resolve-trigger.outputs.base-branch }} - environment: ${{ needs.resolve-environment.outputs.environment }} + environment: ${{ needs.resolve-trigger.outputs.environment }} commit: ${{ needs.resolve-trigger.outputs.commit }} release-version: ${{ needs.resolve-trigger.outputs.release-version }} release-studioctl: name: Release studioctl - if: needs.resolve-trigger.outputs.component == 'studioctl' - needs: - - resolve-trigger - - resolve-environment + if: needs.resolve-trigger.outputs.publisher == 'studioctl' + needs: resolve-trigger permissions: contents: write uses: ./.github/workflows/release-studioctl.yaml @@ -149,11 +129,9 @@ jobs: if: >- always() && needs.resolve-trigger.result == 'success' && - needs.resolve-environment.result == 'success' && - needs.resolve-trigger.outputs.component != '' + needs.resolve-trigger.outputs.publisher != '' needs: - resolve-trigger - - resolve-environment - release-app - release-studioctl runs-on: ubuntu-latest @@ -162,9 +140,10 @@ jobs: env: APP_RESULT: ${{ needs.release-app.result }} COMPONENT: ${{ needs.resolve-trigger.outputs.component }} + PUBLISHER: ${{ needs.resolve-trigger.outputs.publisher }} STUDIOCTL_RESULT: ${{ needs.release-studioctl.result }} run: | if [[ "$APP_RESULT" == "skipped" && "$STUDIOCTL_RESULT" == "skipped" ]]; then - echo "No publisher job is configured for resolved component: $COMPONENT" >&2 + echo "No publisher job is configured for $COMPONENT publisher: $PUBLISHER" >&2 exit 1 fi diff --git a/src/tools/releaser/README.md b/src/tools/releaser/README.md index 691d1186822..c6cded29d5b 100644 --- a/src/tools/releaser/README.md +++ b/src/tools/releaser/README.md @@ -37,9 +37,10 @@ Context: on `main` and starts the newer line at `.1`. 3. Approve and merge the prep PR. 4. CI detects the changelog promotion in the canonical `main` push and runs automatically, including for PRs - from forks. It calls: - - `go run . workflow -component -base-branch main` -5. Workflow resolves the latest prerelease from the component changelog, builds artifacts (if applicable), creates tag `/v...`, and creates a draft prerelease. + from forks. The dispatcher resolves and passes an immutable component, version, commit, and branch plan to the + selected publisher. +5. The publisher verifies that plan, builds artifacts (if applicable), creates tag `/v...`, and creates a + draft prerelease. ## Stable releases @@ -101,6 +102,8 @@ prerelease, stabilization, and patch release flows. - Manual workflow dispatch is a recovery path. Select the component, enter the exact promoted version and full commit SHA, and dispatch from `main` or the matching `release//vX.Y` branch. A matching existing draft release is updated in place so retries can continue after a later publication step fails. -- Release publication depends on the unified CI workflow routing the component to its reusable publisher workflow. +- The Go trigger policy is the publication source of truth. The component registry selects the reusable publisher; + version policy maps `preview` releases to the `dev` environment, `rc` releases to `staging`, and stable releases to + `prod`. Unknown prerelease channels fail closed during trigger resolution. - Manual dispatch validates the exact selected version against the selected commit and branch; publishers never select a newer version or move their checkout while executing a release plan. diff --git a/src/tools/releaser/internal/component.go b/src/tools/releaser/internal/component.go index 0cff6172839..1b599a4f686 100644 --- a/src/tools/releaser/internal/component.go +++ b/src/tools/releaser/internal/component.go @@ -24,11 +24,11 @@ type ComponentBuilder interface { // Component represents a releasable component in the repository. type Component struct { - Builder ComponentBuilder - Name string - ChangelogPath string - SourcePath string - HasReleasePublisher bool + Builder ComponentBuilder + Name string + ChangelogPath string + SourcePath string + Publisher ReleasePublisher } // Component registry. @@ -36,25 +36,25 @@ type Component struct { //nolint:gochecknoglobals // registry pattern var components = map[string]*Component{ "studioctl": { - Name: "studioctl", - ChangelogPath: "src/cli/CHANGELOG.md", - SourcePath: "src/cli", - Builder: nil, // registered by the releaser CLI - HasReleasePublisher: true, + Name: "studioctl", + ChangelogPath: "src/cli/CHANGELOG.md", + SourcePath: "src/cli", + Builder: nil, // registered by the releaser CLI + Publisher: ReleasePublisherStudioctl, }, "fileanalyzers": { - Name: "fileanalyzers", - ChangelogPath: "src/App/fileanalyzers/CHANGELOG.md", - SourcePath: "src/App/fileanalyzers", - Builder: nil, // YAML handles dotnet pack/push - HasReleasePublisher: false, + Name: "fileanalyzers", + ChangelogPath: "src/App/fileanalyzers/CHANGELOG.md", + SourcePath: "src/App/fileanalyzers", + Builder: nil, // YAML handles dotnet pack/push + Publisher: ReleasePublisherNone, }, "app": { - Name: "app", - ChangelogPath: "src/App/backend/CHANGELOG.md", - SourcePath: "src/App/backend", - Builder: nil, // registered by the releaser CLI - HasReleasePublisher: true, + Name: "app", + ChangelogPath: "src/App/backend/CHANGELOG.md", + SourcePath: "src/App/backend", + Builder: nil, // registered by the releaser CLI + Publisher: ReleasePublisherApp, }, } diff --git a/src/tools/releaser/internal/release_policy.go b/src/tools/releaser/internal/release_policy.go new file mode 100644 index 00000000000..60f23101022 --- /dev/null +++ b/src/tools/releaser/internal/release_policy.go @@ -0,0 +1,54 @@ +package internal + +import ( + "errors" + "fmt" + "strings" + + "altinn.studio/releaser/internal/version" +) + +// ReleasePublisher identifies a statically wired reusable publisher workflow. +type ReleasePublisher string + +// Supported release publishers. +const ( + ReleasePublisherNone ReleasePublisher = "" + ReleasePublisherApp ReleasePublisher = "app" + ReleasePublisherStudioctl ReleasePublisher = "studioctl" +) + +// ReleaseEnvironment identifies the protected GitHub environment for publication. +type ReleaseEnvironment string + +// Supported release environments. +const ( + ReleaseEnvironmentDev ReleaseEnvironment = "dev" + ReleaseEnvironmentStaging ReleaseEnvironment = "staging" + ReleaseEnvironmentProd ReleaseEnvironment = "prod" +) + +var ( + errReleaseChannelUnsupported = errors.New("unsupported prerelease channel") + errReleasePublisherUnavailable = errors.New("component has no release publisher") +) + +func resolveReleaseEnvironment(releaseVersion string) (ReleaseEnvironment, error) { + parsed, err := version.Parse(normalizeVersionPrefix(releaseVersion)) + if err != nil { + return "", fmt.Errorf("parse release version: %w", err) + } + if !parsed.IsPrerelease { + return ReleaseEnvironmentProd, nil + } + + channel, _, _ := strings.Cut(parsed.Prerelease, ".") + switch channel { + case "preview": + return ReleaseEnvironmentDev, nil + case "rc": + return ReleaseEnvironmentStaging, nil + default: + return "", fmt.Errorf("%w: %s", errReleaseChannelUnsupported, channel) + } +} diff --git a/src/tools/releaser/internal/release_policy_internal_test.go b/src/tools/releaser/internal/release_policy_internal_test.go new file mode 100644 index 00000000000..fff70d8b67e --- /dev/null +++ b/src/tools/releaser/internal/release_policy_internal_test.go @@ -0,0 +1,64 @@ +package internal + +import ( + "errors" + "testing" + + "altinn.studio/releaser/internal/version" +) + +func TestResolveReleaseEnvironment(t *testing.T) { + t.Parallel() + + tests := []struct { + wantErr error + name string + version string + want ReleaseEnvironment + }{ + { + name: "stable", + version: "v1.2.3", + want: ReleaseEnvironmentProd, + }, + { + name: "preview", + version: "1.2.3-preview.4", + want: ReleaseEnvironmentDev, + }, + { + name: "release candidate", + version: "v1.2.3-rc.2", + want: ReleaseEnvironmentStaging, + }, + { + name: "unsupported prerelease channel", + version: "v1.2.3-alpha.1", + wantErr: errReleaseChannelUnsupported, + }, + { + name: "invalid version", + version: "not-a-version", + wantErr: version.ErrInvalidFormat, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := resolveReleaseEnvironment(tc.version) + if tc.wantErr != nil { + if !errors.Is(err, tc.wantErr) { + t.Fatalf("resolveReleaseEnvironment() error = %v, want %v", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("resolveReleaseEnvironment() error = %v", err) + } + if got != tc.want { + t.Fatalf("resolveReleaseEnvironment() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/src/tools/releaser/internal/release_trigger.go b/src/tools/releaser/internal/release_trigger.go index 5899309fb77..d724ed47d5f 100644 --- a/src/tools/releaser/internal/release_trigger.go +++ b/src/tools/releaser/internal/release_trigger.go @@ -35,10 +35,12 @@ type ReleaseTriggerRequest struct { // ReleasePlan is the immutable release context emitted to CI. type ReleasePlan struct { - Component string `json:"component"` - BaseBranch string `json:"baseBranch"` - Commit string `json:"commit"` - ReleaseVersion string `json:"releaseVersion"` + Component string `json:"component"` + Publisher ReleasePublisher `json:"publisher"` + Environment ReleaseEnvironment `json:"environment"` + BaseBranch string `json:"baseBranch"` + Commit string `json:"commit"` + ReleaseVersion string `json:"releaseVersion"` } // ReleaseTriggerResult either contains one complete release plan or represents a no-op. @@ -100,13 +102,14 @@ func resolveReleaseTriggerWithDeps( return ReleaseTriggerResult{}, fmt.Errorf("load %s changelog at %s: %w", component.Name, commit, err) } selectedVersion := normalizeVersionPrefix(req.SelectedVersion) - if err := validateWorkflowReleasePlan(component, req.RefName, selectedVersion); err != nil { - return ReleaseTriggerResult{}, fmt.Errorf("validate manual release plan: %w", err) + result, err := releaseTriggerResult(component, req.RefName, commit, selectedVersion) + if err != nil { + return ReleaseTriggerResult{}, fmt.Errorf("build manual release plan: %w", err) } if !cl.HasVersion(selectedVersion) { return ReleaseTriggerResult{}, fmt.Errorf("%w: %s", errReleaseTriggerVersionMissing, selectedVersion) } - return releaseTriggerResult(component.Name, req.RefName, commit, selectedVersion), nil + return result, nil case "push": return resolvePushReleaseTrigger(ctx, req, git) default: @@ -160,10 +163,15 @@ func resolvePushReleaseTrigger( return ReleaseTriggerResult{Release: nil}, nil case 1: promotion := promotedComponents[0] - if _, err := validateReleaseTriggerComponent(promotion.component, req.RefName); err != nil { + component, err := validateReleaseTriggerComponent(promotion.component, req.RefName) + if err != nil { return ReleaseTriggerResult{}, err } - return releaseTriggerResult(promotion.component, req.RefName, req.Commit, promotion.version), nil + result, err := releaseTriggerResult(component, req.RefName, req.Commit, promotion.version) + if err != nil { + return ReleaseTriggerResult{}, fmt.Errorf("build push release plan: %w", err) + } + return result, nil default: return ReleaseTriggerResult{}, fmt.Errorf( "%w: %s", @@ -188,7 +196,7 @@ func releaseTriggerPromotions( promotedComponents := make([]releaseTriggerPromotion, 0, 1) for _, name := range componentNames { component := components[name] - if !component.HasReleasePublisher { + if component.Publisher == ReleasePublisherNone { continue } if _, changed := changedFiles[component.ChangelogPath]; !changed { @@ -255,13 +263,29 @@ func changedFileSet(output string) map[string]struct{} { return files } -func releaseTriggerResult(component, baseBranch, commit, releaseVersion string) ReleaseTriggerResult { +func releaseTriggerResult( + component *Component, + baseBranch, commit, releaseVersion string, +) (ReleaseTriggerResult, error) { + if err := validateWorkflowReleasePlan(component, baseBranch, releaseVersion); err != nil { + return ReleaseTriggerResult{}, fmt.Errorf("validate release plan: %w", err) + } + if component.Publisher == ReleasePublisherNone { + return ReleaseTriggerResult{}, fmt.Errorf("%w: %s", errReleasePublisherUnavailable, component.Name) + } + environment, err := resolveReleaseEnvironment(releaseVersion) + if err != nil { + return ReleaseTriggerResult{}, fmt.Errorf("resolve release environment: %w", err) + } + return ReleaseTriggerResult{Release: &ReleasePlan{ - Component: component, + Component: component.Name, + Publisher: component.Publisher, + Environment: environment, BaseBranch: baseBranch, Commit: commit, ReleaseVersion: releaseVersion, - }} + }}, nil } func validateReleaseTriggerComponent(component, baseBranch string) (*Component, error) { diff --git a/src/tools/releaser/internal/release_trigger_internal_test.go b/src/tools/releaser/internal/release_trigger_internal_test.go index 40ff4ae5781..70bb9c61141 100644 --- a/src/tools/releaser/internal/release_trigger_internal_test.go +++ b/src/tools/releaser/internal/release_trigger_internal_test.go @@ -10,9 +10,10 @@ import ( ) const ( - triggerBeforeSHA = "abcdef0123456789abcdef0123456789abcdef01" - triggerHeadSHA = "0123456789abcdef0123456789abcdef01234567" - triggerVersion = "v1.0.0" + triggerBeforeSHA = "abcdef0123456789abcdef0123456789abcdef01" + triggerHeadSHA = "0123456789abcdef0123456789abcdef01234567" + triggerVersion = "v1.0.0-preview.1" + triggerStableVersion = "v1.0.0" triggerBaseChangelog = `# Changelog @@ -26,7 +27,7 @@ const ( ## [Unreleased] -## [1.0.0] - 2026-08-06 +## [1.0.0-preview.1] - 2026-08-06 ### Added @@ -40,7 +41,7 @@ const ( - Arrived after the promotion -## [1.0.0] - 2026-08-06 +## [1.0.0-preview.1] - 2026-08-06 ### Added @@ -268,7 +269,7 @@ func TestResolveReleaseTriggerManual(t *testing.T) { if err != nil { t.Fatalf("resolveReleaseTriggerWithDeps() error = %v", err) } - want := releaseTriggerResult(tc.component, tc.refName, triggerHeadSHA, tc.version) + want := expectedReleaseTriggerResult(tc.component, tc.refName, tc.version) if !reflect.DeepEqual(got, want) { t.Fatalf("resolveReleaseTriggerWithDeps() = %+v, want %+v", got, want) } @@ -346,7 +347,7 @@ func TestResolveReleaseTriggerManualCanRecoverOlderVersion(t *testing.T) { if err != nil { t.Fatalf("resolveReleaseTriggerWithDeps() error = %v", err) } - want := releaseTriggerResult("app", "main", triggerHeadSHA, selectedVersion) + want := expectedReleaseTriggerResult("app", "main", selectedVersion) if !reflect.DeepEqual(got, want) { t.Fatalf("resolveReleaseTriggerWithDeps() = %+v, want %+v", got, want) } @@ -396,14 +397,21 @@ func TestResolveReleaseTriggerPush(t *testing.T) { refName: "main", changed: []string{appPath}, changelogs: map[string]triggerChangelogPair{appPath: promotion}, - want: releaseTriggerResult("app", "main", triggerHeadSHA, triggerVersion), + want: expectedReleaseTriggerResult("app", "main", triggerVersion), }, { - name: "studioctl promotion on release branch", - refName: "release/studioctl/v1.2", - changed: []string{studioctlPath}, - changelogs: map[string]triggerChangelogPair{studioctlPath: promotion}, - want: releaseTriggerResult("studioctl", "release/studioctl/v1.2", triggerHeadSHA, triggerVersion), + name: "studioctl promotion on release branch", + refName: "release/studioctl/v1.0", + changed: []string{studioctlPath}, + changelogs: map[string]triggerChangelogPair{studioctlPath: { + base: triggerStabilizationBase, + head: triggerStabilizedChangelog, + }}, + want: expectedReleaseTriggerResult( + "studioctl", + "release/studioctl/v1.0", + triggerStableVersion, + ), }, { name: "ordinary changelog update is a no-op", @@ -430,7 +438,7 @@ func TestResolveReleaseTriggerPush(t *testing.T) { appPath: promotion, studioctlPath: ordinary, }, - want: releaseTriggerResult("app", "main", triggerHeadSHA, triggerVersion), + want: expectedReleaseTriggerResult("app", "main", triggerVersion), }, { name: "promotion remains detectable beside a later entry for the same component", @@ -439,7 +447,7 @@ func TestResolveReleaseTriggerPush(t *testing.T) { changelogs: map[string]triggerChangelogPair{ appPath: {base: triggerBaseChangelog, head: triggerPromotedWithNewEntry}, }, - want: releaseTriggerResult("app", "main", triggerHeadSHA, triggerVersion), + want: expectedReleaseTriggerResult("app", "main", triggerVersion), }, { name: "multiple promotions fail closed", @@ -485,7 +493,11 @@ func TestResolveReleaseTriggerPush(t *testing.T) { changelogs: map[string]triggerChangelogPair{ appPath: {base: triggerStabilizationBase, head: triggerStabilizedChangelog}, }, - want: releaseTriggerResult("app", "release/app/v1.0", triggerHeadSHA, triggerVersion), + want: expectedReleaseTriggerResult( + "app", + "release/app/v1.0", + triggerStableVersion, + ), }, { name: "component without a publisher is ignored", @@ -620,6 +632,24 @@ func TestResolveReleaseTriggerPushRequiredInputs(t *testing.T) { }) } +func expectedReleaseTriggerResult( + componentName, baseBranch, releaseVersion string, +) ReleaseTriggerResult { + component := components[componentName] + environment, err := resolveReleaseEnvironment(releaseVersion) + if err != nil { + panic(err) + } + return ReleaseTriggerResult{Release: &ReleasePlan{ + Component: component.Name, + Publisher: component.Publisher, + Environment: environment, + BaseBranch: baseBranch, + Commit: triggerHeadSHA, + ReleaseVersion: releaseVersion, + }} +} + func triggerReleasedChangelog(version string) string { return fmt.Sprintf(`# Changelog From 7516ac646662b904e21879126e3479f4d13ad29d Mon Sep 17 00:00:00 2001 From: "Martin Othamar (Agent)" Date: Fri, 7 Aug 2026 08:23:40 +0000 Subject: [PATCH 5/7] chore(releaser): protect publication policy --- .github/CODEOWNERS | 2 ++ .github/scripts/codeowners-generate.mjs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index edfa58e7dfa..367358e372d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -142,6 +142,8 @@ src/test/K6/**/yarn.lock @altin src/tools/health/**/go.mod @altinn/team-altinn-studio-kjoring src/tools/health/**/go.sum @altinn/team-altinn-studio-kjoring src/tools/releaser/**/go.mod @altinn/team-altinn-studio-kjoring +src/tools/releaser/internal/component.go @altinn/team-altinn-studio-kjoring +src/tools/releaser/internal/release_policy.go @altinn/team-altinn-studio-kjoring src/tools/releaser/internal/release_trigger.go @altinn/team-altinn-studio-kjoring # Squad Flyt diff --git a/.github/scripts/codeowners-generate.mjs b/.github/scripts/codeowners-generate.mjs index 22dfc44bf91..859f1998c83 100644 --- a/.github/scripts/codeowners-generate.mjs +++ b/.github/scripts/codeowners-generate.mjs @@ -135,6 +135,8 @@ const GROUPS = [ '.github/workflows/template-runtime-construct-environments.yaml', '.github/workflows/template-studio-construct-environments.yaml', '.github/workflows/validate-renovate.yaml', + 'src/tools/releaser/internal/component.go', + 'src/tools/releaser/internal/release_policy.go', 'src/tools/releaser/internal/release_trigger.go', ], }, From c25c7a05e5c831ceccacb3cc42643651942b0970 Mon Sep 17 00:00:00 2001 From: "Martin Othamar (Agent)" Date: Fri, 7 Aug 2026 08:10:44 +0000 Subject: [PATCH 6/7] ci(releaser): serialize component publication --- .github/workflows/release-app.yaml | 4 ++++ .github/workflows/release-studioctl.yaml | 4 ++++ src/tools/releaser/README.md | 2 ++ 3 files changed, 10 insertions(+) diff --git a/.github/workflows/release-app.yaml b/.github/workflows/release-app.yaml index ac389fd6bf6..6fa1b228495 100644 --- a/.github/workflows/release-app.yaml +++ b/.github/workflows/release-app.yaml @@ -18,6 +18,10 @@ on: required: true type: string +concurrency: + group: release-app-${{ inputs.base-branch }} + cancel-in-progress: false + jobs: release: name: Build, release and publish app diff --git a/.github/workflows/release-studioctl.yaml b/.github/workflows/release-studioctl.yaml index a1c80cc797b..e63cc8a6d0a 100644 --- a/.github/workflows/release-studioctl.yaml +++ b/.github/workflows/release-studioctl.yaml @@ -13,6 +13,10 @@ on: required: true type: string +concurrency: + group: release-studioctl-${{ inputs.base-branch }} + cancel-in-progress: false + jobs: release: name: Build and release studioctl diff --git a/src/tools/releaser/README.md b/src/tools/releaser/README.md index c6cded29d5b..017bbe4001b 100644 --- a/src/tools/releaser/README.md +++ b/src/tools/releaser/README.md @@ -105,5 +105,7 @@ prerelease, stabilization, and patch release flows. - The Go trigger policy is the publication source of truth. The component registry selects the reusable publisher; version policy maps `preview` releases to the `dev` environment, `rc` releases to `staging`, and stable releases to `prod`. Unknown prerelease channels fail closed during trigger resolution. +- Publisher workflows serialize releases for the same component and base branch without cancelling an in-progress + publication. Different components and release lines can still publish independently. - Manual dispatch validates the exact selected version against the selected commit and branch; publishers never select a newer version or move their checkout while executing a release plan. From a2663782a635f6a2ca87ecd12a55cd57ce6a4421 Mon Sep 17 00:00:00 2001 From: "Martin Othamar (Agent)" Date: Fri, 7 Aug 2026 08:24:40 +0000 Subject: [PATCH 7/7] fix(releaser): retain queued publications --- .github/workflows/release-app.yaml | 1 + .github/workflows/release-studioctl.yaml | 1 + src/tools/releaser/README.md | 5 +++-- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-app.yaml b/.github/workflows/release-app.yaml index 6fa1b228495..27535c34ed3 100644 --- a/.github/workflows/release-app.yaml +++ b/.github/workflows/release-app.yaml @@ -20,6 +20,7 @@ on: concurrency: group: release-app-${{ inputs.base-branch }} + queue: max cancel-in-progress: false jobs: diff --git a/.github/workflows/release-studioctl.yaml b/.github/workflows/release-studioctl.yaml index e63cc8a6d0a..1b297f6a5e3 100644 --- a/.github/workflows/release-studioctl.yaml +++ b/.github/workflows/release-studioctl.yaml @@ -15,6 +15,7 @@ on: concurrency: group: release-studioctl-${{ inputs.base-branch }} + queue: max cancel-in-progress: false jobs: diff --git a/src/tools/releaser/README.md b/src/tools/releaser/README.md index 017bbe4001b..513e58544cb 100644 --- a/src/tools/releaser/README.md +++ b/src/tools/releaser/README.md @@ -105,7 +105,8 @@ prerelease, stabilization, and patch release flows. - The Go trigger policy is the publication source of truth. The component registry selects the reusable publisher; version policy maps `preview` releases to the `dev` environment, `rc` releases to `staging`, and stable releases to `prod`. Unknown prerelease channels fail closed during trigger resolution. -- Publisher workflows serialize releases for the same component and base branch without cancelling an in-progress - publication. Different components and release lines can still publish independently. +- Publisher workflows serialize releases for the same component and base branch with GitHub's maximum pending queue, + without cancelling an in-progress publication. Different components and release lines can still publish + independently. - Manual dispatch validates the exact selected version against the selected commit and branch; publishers never select a newer version or move their checkout while executing a release plan.