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', ], }, diff --git a/.github/workflows/release-app.yaml b/.github/workflows/release-app.yaml index ac389fd6bf6..27535c34ed3 100644 --- a/.github/workflows/release-app.yaml +++ b/.github/workflows/release-app.yaml @@ -18,6 +18,11 @@ on: required: true type: string +concurrency: + group: release-app-${{ inputs.base-branch }} + queue: max + cancel-in-progress: false + jobs: release: name: Build, release and publish app diff --git a/.github/workflows/release-components.yaml b/.github/workflows/release-components.yaml index bd398160693..21fe32a53a8 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 @@ -27,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 }} @@ -50,9 +60,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,15 +72,20 @@ 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")" + 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" @@ -82,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 @@ -139,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 @@ -152,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/.github/workflows/release-studioctl.yaml b/.github/workflows/release-studioctl.yaml index a1c80cc797b..1b297f6a5e3 100644 --- a/.github/workflows/release-studioctl.yaml +++ b/.github/workflows/release-studioctl.yaml @@ -13,6 +13,11 @@ on: required: true type: string +concurrency: + group: release-studioctl-${{ inputs.base-branch }} + queue: max + 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 b7351846fef..513e58544cb 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 @@ -98,8 +99,14 @@ 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. -- 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 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. +- 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 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. 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/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/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/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/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 e4acf9aa41e..d724ed47d5f 100644 --- a/src/tools/releaser/internal/release_trigger.go +++ b/src/tools/releaser/internal/release_trigger.go @@ -9,12 +9,15 @@ 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") + errReleaseTriggerCommitExact = errors.New("manual release commit must be a full commit SHA") + errReleaseTriggerPromotionCount = errors.New( "push contains release promotions for multiple components", ) ) @@ -27,14 +30,17 @@ type ReleaseTriggerRequest struct { Commit string BeforeSHA string SelectedComponent string + SelectedVersion string } // 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. @@ -77,6 +83,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 +93,23 @@ func resolveReleaseTriggerWithDeps( if git == nil { return ReleaseTriggerResult{}, errGitRequired } - cl, err := loadChangelogAtRef(ctx, git, req.Commit, component.ChangelogPath) + commit, err := resolveExactManualCommit(ctx, git, req.Commit) if err != nil { - return ReleaseTriggerResult{}, fmt.Errorf("load %s changelog at %s: %w", component.Name, req.Commit, err) + return ReleaseTriggerResult{}, 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) + 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, req.Commit, resolvedVersion), nil + return result, nil case "push": return resolvePushReleaseTrigger(ctx, req, git) default: @@ -100,6 +117,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, @@ -130,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", @@ -158,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 { @@ -225,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 6280ef980d5..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 = "abcdef0123456789" - triggerHeadSHA = "0123456789abcdef" - 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 @@ -239,10 +240,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 +258,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) { @@ -262,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) } @@ -270,6 +277,103 @@ 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 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() + + 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 := expectedReleaseTriggerResult("app", "main", 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() @@ -293,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", @@ -327,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", @@ -336,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", @@ -382,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", @@ -517,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 diff --git a/src/tools/releaser/internal/workflow.go b/src/tools/releaser/internal/workflow.go index e1bb5fdf876..606c73109a4 100644 --- a/src/tools/releaser/internal/workflow.go +++ b/src/tools/releaser/internal/workflow.go @@ -17,11 +17,14 @@ 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") + 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") ) // WorkflowConfig configures the release workflow. @@ -50,6 +53,7 @@ type Workflow struct { artifacts []string topology RepositoryTopology config WorkflowConfig + resumeDraft bool } // NewWorkflow creates a new Workflow instance. @@ -117,6 +121,7 @@ func NewWorkflow( parsedChangelog: nil, artifacts: nil, topology: topology, + resumeDraft: false, }, nil } @@ -193,7 +198,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 +255,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 +287,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 +549,42 @@ 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 { + 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..b5f9c0489d2 100644 --- a/src/tools/releaser/internal/workflow_test.go +++ b/src/tools/releaser/internal/workflow_test.go @@ -104,6 +104,178 @@ 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 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() + + 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() @@ -550,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 } @@ -561,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 @@ -612,41 +790,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 +861,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