Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/scripts/codeowners-generate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
},
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release-app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 26 additions & 37 deletions .github/workflows/release-components.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
Expand All @@ -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 \
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
5 changes: 5 additions & 0 deletions .github/workflows/release-studioctl.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 15 additions & 8 deletions src/tools/releaser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ Context: on `main`
and starts the newer line at `<channel>.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 <component> -base-branch main`
5. Workflow resolves the latest prerelease from the component changelog, builds artifacts (if applicable), creates tag `<component>/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 `<component>/v...`, and creates a
draft prerelease.

## Stable releases

Expand Down Expand Up @@ -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/<component>/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/<component>/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.
40 changes: 20 additions & 20 deletions src/tools/releaser/internal/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,37 +24,37 @@ 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.
//
//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,
},
}

Expand Down
27 changes: 27 additions & 0 deletions src/tools/releaser/internal/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading