refactor: prepare to decouple the "model migration" package and "models" package (#38533)

Migrations should never use model structs directly, because the model
structs can be different in different releases. e.g. if one migration uses
"User" model, it works in the early releases, then one day, when the
User model changes, the migration breaks because it will use the
new (incorrect) User model, it should only use the old User model.

The same to "modules/structs".

---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: delvh <dev.lh@web.de>
This commit is contained in:
wxiaoguang
2026-07-20 03:42:02 +00:00
committed by GitHub
co-authored by delvh
parent 775e3bdb34
commit fff32e9469
378 changed files with 708 additions and 721 deletions
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"testing"
"gitea.dev/modelmigration/migrationtest"
)
func TestMain(m *testing.M) {
migrationtest.MainTest(m)
}
+158
View File
@@ -0,0 +1,158 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"context"
"time"
"gitea.dev/modelmigration/base"
"gitea.dev/modules/timeutil"
"xorm.io/xorm"
)
type actionRunAttempt struct {
ID int64
RepoID int64 `xorm:"index(repo_concurrency_status)"`
RunID int64 `xorm:"UNIQUE(run_attempt)"`
Attempt int64 `xorm:"UNIQUE(run_attempt)"`
TriggerUserID int64
ConcurrencyGroup string `xorm:"index(repo_concurrency_status) NOT NULL DEFAULT ''"`
ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"`
Status int `xorm:"index(repo_concurrency_status)"`
Started timeutil.TimeStamp
Stopped timeutil.TimeStamp
Created timeutil.TimeStamp `xorm:"created"`
Updated timeutil.TimeStamp `xorm:"updated"`
}
func (actionRunAttempt) TableName() string {
return "action_run_attempt"
}
type actionArtifact struct {
ID int64 `xorm:"pk autoincr"`
RunID int64 `xorm:"index unique(runid_attempt_name_path)"`
RunAttemptID int64 `xorm:"index unique(runid_attempt_name_path) NOT NULL DEFAULT 0"`
RunnerID int64
RepoID int64 `xorm:"index"`
OwnerID int64
CommitSHA string
StoragePath string
FileSize int64
FileCompressedSize int64
ContentEncoding string `xorm:"content_encoding"`
ArtifactPath string `xorm:"index unique(runid_attempt_name_path)"`
ArtifactName string `xorm:"index unique(runid_attempt_name_path)"`
Status int `xorm:"index"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated index"`
ExpiredUnix timeutil.TimeStamp `xorm:"index"`
}
func (actionArtifact) TableName() string {
return "action_artifact"
}
// actionRun mirrors the post-migration action_run schema.
type actionRun struct {
ID int64
Title string
RepoID int64 `xorm:"unique(repo_index)"`
OwnerID int64 `xorm:"index"`
WorkflowID string `xorm:"index"`
Index int64 `xorm:"index unique(repo_index)"`
TriggerUserID int64 `xorm:"index"`
ScheduleID int64
Ref string `xorm:"index"`
CommitSHA string
IsForkPullRequest bool
NeedApproval bool
ApprovedBy int64 `xorm:"index"`
Event string
EventPayload string `xorm:"LONGTEXT"`
TriggerEvent string
Status int `xorm:"index"`
Version int `xorm:"version default 0"`
RawConcurrency string
Started timeutil.TimeStamp
Stopped timeutil.TimeStamp
PreviousDuration time.Duration
LatestAttemptID int64 `xorm:"index NOT NULL DEFAULT 0"`
Created timeutil.TimeStamp `xorm:"created"`
Updated timeutil.TimeStamp `xorm:"updated"`
}
func (actionRun) TableName() string {
return "action_run"
}
// AddActionRunAttemptModel adds the ActionRunAttempt table and the supporting ActionRun/ActionRunJob fields.
func AddActionRunAttemptModel(x base.EngineMigration) error {
// add "action_run_attempt"
if _, err := x.SyncWithOptions(xorm.SyncOptions{
IgnoreDropIndices: true,
}, new(actionRunAttempt)); err != nil {
return err
}
// update "action_run_job"
type ActionRunJob struct {
RunAttemptID int64 `xorm:"index NOT NULL DEFAULT 0"`
AttemptJobID int64 `xorm:"index NOT NULL DEFAULT 0"`
SourceTaskID int64 `xorm:"NOT NULL DEFAULT 0"`
}
if _, err := x.SyncWithOptions(xorm.SyncOptions{
IgnoreDropIndices: true,
}, new(ActionRunJob)); err != nil {
return err
}
// update "action_artifact": let xorm sync add the new 4-column unique index (runid_attempt_name_path) and drop the old 3-column unique (runid_name_path)
if err := x.Sync(new(actionArtifact)); err != nil {
return err
}
// update "action_run"
//
// This migration intentionally removes the legacy run-level concurrency columns after
// introducing attempt-level concurrency on action_run_attempt.
//
// Existing values from action_run.concurrency_group / action_run.concurrency_cancel are
// not backfilled into action_run_attempt:
// - the old fields are only meaningful while a run is actively participating in
// concurrency scheduling
// - for completed legacy runs, keeping or backfilling those values has no practical
// effect on future scheduling behavior
// - scanning and backfilling old runs would add significant migration cost for little value
//
// This means the schema change is destructive for those two legacy columns by design.
//
// Let xorm sync add the latest_attempt_id column and drop the now-orphan (repo_id, concurrency_group) index.
if err := x.Sync(new(actionRun)); err != nil {
return err
}
concurrencyColumns := make([]string, 0, 2)
for _, col := range []string{"concurrency_group", "concurrency_cancel"} {
exist, err := x.Dialect().IsColumnExist(x.DB(), context.Background(), "action_run", col)
if err != nil {
return err
}
if exist {
concurrencyColumns = append(concurrencyColumns, col)
}
}
if len(concurrencyColumns) == 0 {
return nil
}
sess := x.NewSession()
defer sess.Close()
if err := base.DropTableColumns(sess, "action_run", concurrencyColumns...); err != nil {
return err
}
// DropTableColumns rebuilds the table on SQLite, which drops all existing indexes.
// Re-sync to restore the indexes defined on actionRun.
return x.Sync(new(actionRun))
}
+156
View File
@@ -0,0 +1,156 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"context"
"slices"
"testing"
"gitea.dev/modelmigration/migrationtest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm/schemas"
)
type actionRunBeforeV331 struct {
ID int64 `xorm:"pk autoincr"`
ConcurrencyGroup string
ConcurrencyCancel bool
LatestAttemptID int64 `xorm:"-"`
}
func (actionRunBeforeV331) TableName() string {
return "action_run"
}
type actionRunJobBeforeV331 struct {
ID int64 `xorm:"pk autoincr"`
RunID int64 `xorm:"index"`
RepoID int64 `xorm:"index"`
}
func (actionRunJobBeforeV331) TableName() string {
return "action_run_job"
}
type actionArtifactBeforeV331 struct {
ID int64 `xorm:"pk autoincr"`
RunID int64 `xorm:"index unique(runid_name_path)"`
RepoID int64 `xorm:"index"`
ArtifactPath string `xorm:"index unique(runid_name_path)"`
ArtifactName string `xorm:"index unique(runid_name_path)"`
}
func (actionArtifactBeforeV331) TableName() string {
return "action_artifact"
}
func Test_AddActionRunAttemptModel(t *testing.T) {
x, deferable := migrationtest.PrepareTestEnv(t, 0,
new(actionRunBeforeV331),
new(actionRunJobBeforeV331),
new(actionArtifactBeforeV331),
)
defer deferable()
if x == nil || t.Failed() {
return
}
_, err := x.Insert(&actionArtifactBeforeV331{
RunID: 1,
RepoID: 1,
ArtifactPath: "artifact/path",
ArtifactName: "artifact-name",
})
require.NoError(t, err)
require.NoError(t, AddActionRunAttemptModel(x))
tableMap := migrationtest.LoadTableSchemasMap(t, x)
attemptTable := tableMap["action_run_attempt"]
require.NotNil(t, attemptTable)
attemptTablCols := []string{"id", "repo_id", "run_id", "attempt", "trigger_user_id", "status", "started", "stopped", "concurrency_group", "concurrency_cancel", "created", "updated"}
require.ElementsMatch(t, attemptTable.ColumnsSeq(), attemptTablCols)
runTable := tableMap["action_run"]
require.NotNil(t, runTable)
require.Contains(t, runTable.ColumnsSeq(), "latest_attempt_id")
require.NotContains(t, runTable.ColumnsSeq(), "concurrency_group")
require.NotContains(t, runTable.ColumnsSeq(), "concurrency_cancel")
jobTable := tableMap["action_run_job"]
require.NotNil(t, jobTable)
require.Contains(t, jobTable.ColumnsSeq(), "run_attempt_id")
require.Contains(t, jobTable.ColumnsSeq(), "attempt_job_id")
require.Contains(t, jobTable.ColumnsSeq(), "source_task_id")
attemptIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action_run_attempt")
require.NoError(t, err)
assert.True(t, hasIndexWithColumns(attemptIndexes, []string{"run_id", "attempt"}, true))
assert.True(t, hasIndexWithColumns(attemptIndexes, []string{"repo_id", "concurrency_group", "status"}, false))
runIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action_run")
require.NoError(t, err)
assert.True(t, hasIndexWithColumns(runIndexes, []string{"latest_attempt_id"}, false))
assert.False(t, hasIndexWithColumns(runIndexes, []string{"repo_id", "concurrency_group"}, false))
jobIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action_run_job")
require.NoError(t, err)
assert.True(t, hasIndexWithColumns(jobIndexes, []string{"run_attempt_id"}, false))
assert.True(t, hasIndexWithColumns(jobIndexes, []string{"attempt_job_id"}, false))
indexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action_artifact")
require.NoError(t, err)
assert.False(t, hasIndexWithColumns(indexes, []string{"run_id", "artifact_path", "artifact_name"}, true))
assert.True(t, hasIndexWithColumns(indexes, []string{"run_id", "run_attempt_id", "artifact_path", "artifact_name"}, true))
_, err = x.Insert(&actionArtifact{
RunID: 1,
RunAttemptID: 2,
RepoID: 1,
ArtifactPath: "artifact/path",
ArtifactName: "artifact-name",
})
require.NoError(t, err)
_, err = x.Insert(&actionArtifact{
RunID: 1,
RunAttemptID: 2,
RepoID: 1,
ArtifactPath: "artifact/path",
ArtifactName: "artifact-name",
})
require.Error(t, err)
_, err = x.Insert(&actionRunAttempt{
RepoID: 1,
RunID: 1,
Attempt: 2,
TriggerUserID: 1,
Status: 1,
})
require.NoError(t, err)
_, err = x.Insert(&actionRunAttempt{
RepoID: 1,
RunID: 1,
Attempt: 2,
TriggerUserID: 2,
Status: 1,
})
require.Error(t, err)
}
func hasIndexWithColumns(indexes map[string]*schemas.Index, cols []string, isUnique bool) bool {
for _, index := range indexes {
if isUnique && index.Type != schemas.UniqueType {
continue
}
if slices.Equal(index.Cols, cols) {
return true
}
}
return false
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"gitea.dev/modelmigration/base"
"xorm.io/xorm"
)
type mirrorWithLastSyncUnix struct {
LastSyncUnix int64 `xorm:"INDEX"`
}
func (mirrorWithLastSyncUnix) TableName() string {
return "mirror"
}
func AddLastSyncUnixToMirror(x base.EngineMigration) error {
_, err := x.SyncWithOptions(xorm.SyncOptions{
IgnoreDropIndices: true,
}, new(mirrorWithLastSyncUnix))
return err
}
+24
View File
@@ -0,0 +1,24 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"gitea.dev/modelmigration/base"
"xorm.io/xorm"
)
func AddBranchProtectionBypassAllowlist(x base.EngineMigration) error {
type ProtectedBranch struct {
EnableBypassAllowlist bool `xorm:"NOT NULL DEFAULT false"`
BypassAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
BypassAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
}
_, err := x.SyncWithOptions(xorm.SyncOptions{
IgnoreConstrains: true,
IgnoreIndices: true,
}, new(ProtectedBranch))
return err
}
+60
View File
@@ -0,0 +1,60 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"testing"
"gitea.dev/modelmigration/migrationtest"
"github.com/stretchr/testify/require"
)
func Test_AddBranchProtectionBypassAllowlist(t *testing.T) {
type ProtectedBranch struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX"`
BranchName string `xorm:"INDEX"`
EnableBypassAllowlist bool `xorm:"NOT NULL DEFAULT false"`
BypassAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
BypassAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
}
x, deferable := migrationtest.PrepareTestEnv(t, 0, new(ProtectedBranch))
defer deferable()
// Test with default values
_, err := x.Insert(&ProtectedBranch{RepoID: 1, BranchName: "main"})
require.NoError(t, err)
// Test with populated allowlist
_, err = x.Insert(&ProtectedBranch{
RepoID: 1,
BranchName: "develop",
EnableBypassAllowlist: true,
BypassAllowlistUserIDs: []int64{1, 2, 3},
BypassAllowlistTeamIDs: []int64{10, 20},
})
require.NoError(t, err)
require.NoError(t, AddBranchProtectionBypassAllowlist(x))
// Verify the default values record
var pb ProtectedBranch
has, err := x.Where("repo_id = ? AND branch_name = ?", 1, "main").Get(&pb)
require.NoError(t, err)
require.True(t, has)
require.False(t, pb.EnableBypassAllowlist)
require.Nil(t, pb.BypassAllowlistUserIDs)
require.Nil(t, pb.BypassAllowlistTeamIDs)
// Verify the populated allowlist record
var pb2 ProtectedBranch
has, err = x.Where("repo_id = ? AND branch_name = ?", 1, "develop").Get(&pb2)
require.NoError(t, err)
require.True(t, has)
require.True(t, pb2.EnableBypassAllowlist)
require.Equal(t, []int64{1, 2, 3}, pb2.BypassAllowlistUserIDs)
require.Equal(t, []int64{10, 20}, pb2.BypassAllowlistTeamIDs)
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"gitea.dev/modelmigration/base"
"xorm.io/xorm"
)
func AddCancellingSupportToActionRunner(x base.EngineMigration) error {
type ActionRunner struct {
HasCancellingSupport bool `xorm:"has_cancelling_support NOT NULL DEFAULT false"`
}
_, err := x.SyncWithOptions(xorm.SyncOptions{
IgnoreConstrains: true,
IgnoreDropIndices: true,
}, new(ActionRunner))
return err
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"testing"
"gitea.dev/modelmigration/migrationtest"
"github.com/stretchr/testify/require"
)
func TestAddCancellingSupportToActionRunner(t *testing.T) {
type ActionRunner struct {
ID int64 `xorm:"pk autoincr"`
Name string
}
x, deferable := migrationtest.PrepareTestEnv(t, 0, new(ActionRunner))
defer deferable()
if x == nil || t.Failed() {
return
}
_, err := x.Insert(&ActionRunner{Name: "runner"})
require.NoError(t, err)
require.NoError(t, AddCancellingSupportToActionRunner(x))
var hasCancellingSupport bool
has, err := x.SQL("SELECT has_cancelling_support FROM action_runner WHERE id = ?", 1).Get(&hasCancellingSupport)
require.NoError(t, err)
require.True(t, has)
require.False(t, hasCancellingSupport)
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"gitea.dev/modelmigration/base"
"gitea.dev/models/db"
"xorm.io/xorm"
)
// AddReusableWorkflowFieldsToActionRunJob adds the ActionRunJob columns that describe the reusable workflow caller hierarchy,
// and the ActionRunAttemptJobIDIndex table backing run-wide AttemptJobID allocation.
func AddReusableWorkflowFieldsToActionRunJob(x base.EngineMigration) error {
type ActionRunJob struct {
WorkflowSourceRepoID int64 `xorm:"NOT NULL DEFAULT 0"`
WorkflowSourceCommitSHA string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"`
IsReusableCaller bool `xorm:"index NOT NULL DEFAULT FALSE"`
ParentJobID int64 `xorm:"index NOT NULL DEFAULT 0"`
CallUses string `xorm:"VARCHAR(512) NOT NULL DEFAULT ''"`
CallSecrets string `xorm:"LONGTEXT"`
CallPayload string `xorm:"LONGTEXT"`
IsExpanded bool `xorm:"NOT NULL DEFAULT FALSE"`
ReusableWorkflowContent []byte `xorm:"LONGBLOB"`
}
type ActionRunAttemptJobIDIndex db.ResourceIndex
if _, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(ActionRunJob)); err != nil {
return err
}
return x.Sync(new(ActionRunAttemptJobIDIndex))
}
+30
View File
@@ -0,0 +1,30 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"gitea.dev/modelmigration/base"
"gitea.dev/modules/timeutil"
)
func AddActionRunJobSummaryTable(x base.EngineMigration) error {
type ActionRunJobSummary struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE(summary_key)"`
RunID int64 `xorm:"UNIQUE(summary_key)"`
RunAttemptID int64 `xorm:"UNIQUE(summary_key) NOT NULL DEFAULT 0"`
JobID int64 `xorm:"UNIQUE(summary_key)"`
StepIndex int64 `xorm:"UNIQUE(summary_key)"`
Content string `xorm:"LONGTEXT"`
ContentType string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'text/markdown'"`
ContentSize int64 `xorm:"NOT NULL DEFAULT 0"`
Created timeutil.TimeStamp `xorm:"created"`
Updated timeutil.TimeStamp `xorm:"updated"`
}
return x.Sync(new(ActionRunJobSummary))
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"gitea.dev/modelmigration/base"
"xorm.io/xorm"
)
type VisibleType int
type teamWithVisibility struct {
Visibility VisibleType `xorm:"NOT NULL DEFAULT 2"`
}
func (teamWithVisibility) TableName() string {
return "team"
}
func AddVisibilityToTeam(x base.EngineMigration) error {
if _, err := x.SyncWithOptions(xorm.SyncOptions{
IgnoreDropIndices: true,
IgnoreConstrains: true,
}, new(teamWithVisibility)); err != nil {
return err
}
// Owner teams must remain listable to all org members; new orgs create
// them as "limited", so make existing owner teams limited too.
// Filter on authorize=4 (AccessModeOwner) so a user-created team that
// happens to share the name "owners" is not accidentally affected.
_, err := x.Exec("UPDATE `team` SET visibility = ? WHERE lower_name = ? AND authorize = ?", 1, "owners", 4)
return err
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"fmt"
"strings"
"gitea.dev/modelmigration/base"
"xorm.io/xorm/schemas"
)
type issueWithLongTextContent struct {
Content string `xorm:"LONGTEXT"`
}
func (issueWithLongTextContent) TableName() string {
return "issue"
}
type commentWithLongTextFields struct {
Content string `xorm:"LONGTEXT"`
PatchQuoted string `xorm:"LONGTEXT patch"`
}
func (commentWithLongTextFields) TableName() string {
return "comment"
}
func isMSSQLMaxTextColumn(column *schemas.Column) bool {
if column.Length != -1 {
return false
}
return strings.EqualFold(column.SQLType.Name, schemas.Varchar) || strings.EqualFold(column.SQLType.Name, schemas.NVarchar)
}
func modifyLongTextColumnsForMSSQL(x base.EngineMigration, bean any, columnNames ...string) error {
table, err := x.TableInfo(bean)
if err != nil {
return err
}
for _, columnName := range columnNames {
column := table.GetColumn(columnName)
if column == nil {
return fmt.Errorf("column %s does not exist in table %s", columnName, table.Name)
}
if isMSSQLMaxTextColumn(column) {
continue
}
if err := base.ModifyColumn(x, table.Name, column); err != nil {
return fmt.Errorf("modify %s.%s: %w", table.Name, columnName, err)
}
}
return nil
}
// ExpandIssueAndCommentLongTextFieldsForMSSQL expands legacy MSSQL nvarchar(4000)
// columns to nvarchar(max) so PR push comments and long issue content are not truncated.
func ExpandIssueAndCommentLongTextFieldsForMSSQL(x base.EngineMigration) error {
if x.Dialect().URI().DBType != schemas.MSSQL {
return nil
}
if err := modifyLongTextColumnsForMSSQL(x, new(issueWithLongTextContent), "content"); err != nil {
return err
}
return modifyLongTextColumnsForMSSQL(x, new(commentWithLongTextFields), "content", "patch")
}
+52
View File
@@ -0,0 +1,52 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"strings"
"testing"
"gitea.dev/modelmigration/migrationtest"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/require"
)
type issueBeforeLongTextMSSQLMigration struct {
ID int64 `xorm:"pk autoincr"`
Content string `xorm:"VARCHAR(4000)"`
}
func (issueBeforeLongTextMSSQLMigration) TableName() string {
return "issue"
}
type commentBeforeLongTextMSSQLMigration struct {
ID int64 `xorm:"pk autoincr"`
Content string `xorm:"VARCHAR(4000)"`
Patch string `xorm:"VARCHAR(4000) patch"`
}
func (commentBeforeLongTextMSSQLMigration) TableName() string {
return "comment"
}
func Test_ExpandIssueAndCommentLongTextFieldsForMSSQL(t *testing.T) {
if !setting.Database.Type.IsMSSQL() {
t.Skip("Only MSSQL needs to expand legacy nvarchar(4000) long-text columns")
}
x, deferrable := migrationtest.PrepareTestEnv(t, 0, new(issueBeforeLongTextMSSQLMigration), new(commentBeforeLongTextMSSQLMigration))
defer deferrable()
require.NoError(t, ExpandIssueAndCommentLongTextFieldsForMSSQL(x))
require.NoError(t, ExpandIssueAndCommentLongTextFieldsForMSSQL(x))
longText := strings.Repeat("x", 5000)
_, err := x.Insert(&issueBeforeLongTextMSSQLMigration{Content: longText})
require.NoError(t, err)
_, err = x.Insert(&commentBeforeLongTextMSSQLMigration{Content: longText, Patch: longText})
require.NoError(t, err)
}
+40
View File
@@ -0,0 +1,40 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"context"
"gitea.dev/modelmigration/base"
"xorm.io/xorm/schemas"
)
// AddCreatedUnixToActionUserIsDeletedIndex extends the c_u composite index on
// the action table to include created_unix, enabling efficient ORDER BY on the
// dashboard feed query without a full sort of all matching rows.
func AddCreatedUnixToActionUserIsDeletedIndex(x base.EngineMigration) error {
// xorm Sync cannot reliably update an index when another index already
// covers the same columns in a different order (Equal() is order-insensitive).
// Drop the old c_u index explicitly, then recreate it with the new column set.
indexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action")
if err != nil {
return err
}
for _, idx := range indexes {
if idx.Name == "c_u" {
if _, err := x.Exec(x.Dialect().DropIndexSQL("action", idx)); err != nil {
return err
}
break
}
}
newIndex := schemas.NewIndex("c_u", schemas.IndexType)
newIndex.AddColumn("user_id", "is_deleted", "created_unix")
if _, err := x.Exec(x.Dialect().CreateIndexSQL("action", newIndex)); err != nil {
return err
}
return nil
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"context"
"testing"
"gitea.dev/modelmigration/migrationtest"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm/schemas"
)
type actionBeforeV339 struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"INDEX"`
OpType int
ActUserID int64
RepoID int64
CommentID int64 `xorm:"INDEX"`
IsDeleted bool `xorm:"NOT NULL DEFAULT false"`
RefName string
IsPrivate bool `xorm:"NOT NULL DEFAULT false"`
Content string `xorm:"TEXT"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
func (actionBeforeV339) TableName() string { return "action" }
func (actionBeforeV339) TableIndices() []*schemas.Index {
repoIndex := schemas.NewIndex("r_u_d", schemas.IndexType)
repoIndex.AddColumn("repo_id", "user_id", "is_deleted")
actUserIndex := schemas.NewIndex("au_r_c_u_d", schemas.IndexType)
actUserIndex.AddColumn("act_user_id", "repo_id", "created_unix", "user_id", "is_deleted")
cudIndex := schemas.NewIndex("c_u_d", schemas.IndexType)
cudIndex.AddColumn("created_unix", "user_id", "is_deleted")
// old 2-column index, before the migration
cuIndex := schemas.NewIndex("c_u", schemas.IndexType)
cuIndex.AddColumn("user_id", "is_deleted")
actUserUserIndex := schemas.NewIndex("au_c_u", schemas.IndexType)
actUserUserIndex.AddColumn("act_user_id", "created_unix", "user_id")
return []*schemas.Index{actUserIndex, repoIndex, cudIndex, cuIndex, actUserUserIndex}
}
func Test_AddCreatedUnixToActionUserIsDeletedIndex(t *testing.T) {
x, deferable := migrationtest.PrepareTestEnv(t, 0, new(actionBeforeV339))
defer deferable()
if x == nil || t.Failed() {
return
}
indexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action")
require.NoError(t, err)
assert.True(t, hasIndexWithColumns(indexes, []string{"user_id", "is_deleted"}, false), "old c_u index should exist before migration")
assert.False(t, hasIndexWithColumns(indexes, []string{"user_id", "is_deleted", "created_unix"}, false), "new c_u index should not exist before migration")
require.NoError(t, AddCreatedUnixToActionUserIsDeletedIndex(x))
indexes, err = x.Dialect().GetIndexes(x.DB(), context.Background(), "action")
require.NoError(t, err)
assert.False(t, hasIndexWithColumns(indexes, []string{"user_id", "is_deleted"}, false), "old 2-column c_u index should be gone after migration")
assert.True(t, hasIndexWithColumns(indexes, []string{"user_id", "is_deleted", "created_unix"}, false), "new 3-column c_u index must exist after migration")
}
+24
View File
@@ -0,0 +1,24 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"gitea.dev/modelmigration/base"
"xorm.io/xorm"
)
// AddContinueOnErrorToActionRunJob adds the ContinueOnError column to ActionRunJob,
// storing the job-level continue-on-error value from the workflow YAML.
func AddContinueOnErrorToActionRunJob(x base.EngineMigration) error {
type ActionRunJob struct {
ContinueOnError bool `xorm:"NOT NULL DEFAULT FALSE"`
}
_, err := x.SyncWithOptions(xorm.SyncOptions{
IgnoreDropIndices: true,
IgnoreConstrains: true,
}, new(ActionRunJob))
return err
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"fmt"
"strings"
"time"
"gitea.dev/modelmigration/base"
"xorm.io/xorm/schemas"
)
// legacyDateTimeColumns are the persisted real datetime columns that old Gitea
// versions created as MSSQL DATETIME. Every other time value is stored as a
// unix timestamp integer, so these are the only columns affected.
var legacyDateTimeColumns = []struct {
bean any
column string
}{
{new(externalLoginUserWithExpiresAt), "expires_at"},
{new(lfsLockWithCreated), "created"},
}
type externalLoginUserWithExpiresAt struct {
ExpiresAt time.Time
}
func (externalLoginUserWithExpiresAt) TableName() string {
return "external_login_user"
}
type lfsLockWithCreated struct {
Created time.Time `xorm:"created"`
}
func (lfsLockWithCreated) TableName() string {
return "lfs_lock"
}
// FixLegacyMSSQLDateTimeColumns converts legacy locale-dependent DATETIME columns
// to DATETIME2. Databases created by old Gitea versions stored these columns as
// DATETIME, which fails to parse ISO datetime strings ('YYYY-MM-DD HH:MM:SS')
// when the MSSQL session language is not English, breaking external account
// linking and LFS lock creation. New installs already use DATETIME2, so only
// legacy MSSQL columns need converting.
func FixLegacyMSSQLDateTimeColumns(x base.EngineMigration) error {
if x.Dialect().URI().DBType != schemas.MSSQL {
return nil
}
for _, c := range legacyDateTimeColumns {
table, err := x.TableInfo(c.bean)
if err != nil {
return err
}
var dataType string
has, err := x.SQL("SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND COLUMN_NAME = ?", table.Name, c.column).Get(&dataType)
if err != nil {
return err
}
if !has || !strings.EqualFold(dataType, "datetime") {
continue
}
column := table.GetColumn(c.column)
if column == nil {
return fmt.Errorf("column %s does not exist in table %s", c.column, table.Name)
}
if err := base.ModifyColumn(x, table.Name, column); err != nil {
return fmt.Errorf("modify %s.%s: %w", table.Name, c.column, err)
}
}
return nil
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"testing"
"time"
"gitea.dev/modelmigration/base"
"gitea.dev/modelmigration/migrationtest"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/require"
)
type externalLoginUserBeforeDateTimeMigration struct {
ExternalID string `xorm:"pk NOT NULL"`
LoginSourceID int64 `xorm:"pk NOT NULL"`
ExpiresAt time.Time // sync creates DATETIME2; downgraded to legacy DATETIME via raw SQL below
}
func (externalLoginUserBeforeDateTimeMigration) TableName() string {
return "external_login_user"
}
type lfsLockBeforeDateTimeMigration struct {
ID int64 `xorm:"pk autoincr"`
Created time.Time `xorm:"created"`
}
func (lfsLockBeforeDateTimeMigration) TableName() string {
return "lfs_lock"
}
func Test_FixLegacyMSSQLDateTimeColumns(t *testing.T) {
if !setting.Database.Type.IsMSSQL() {
t.Skip("Only MSSQL needs to convert the legacy locale-dependent DATETIME columns")
}
x, deferrable := migrationtest.PrepareTestEnv(t, 0,
new(externalLoginUserBeforeDateTimeMigration),
new(lfsLockBeforeDateTimeMigration),
)
defer deferrable()
// Force the legacy DATETIME column type that old Gitea versions created.
_, err := x.Exec("ALTER TABLE [external_login_user] ALTER COLUMN [expires_at] DATETIME")
require.NoError(t, err)
_, err = x.Exec("ALTER TABLE [lfs_lock] ALTER COLUMN [created] DATETIME")
require.NoError(t, err)
require.Equal(t, "datetime", mssqlColumnType(t, x, "external_login_user", "expires_at"))
require.Equal(t, "datetime", mssqlColumnType(t, x, "lfs_lock", "created"))
require.NoError(t, FixLegacyMSSQLDateTimeColumns(x))
require.NoError(t, FixLegacyMSSQLDateTimeColumns(x)) // idempotent
require.Equal(t, "datetime2", mssqlColumnType(t, x, "external_login_user", "expires_at"))
require.Equal(t, "datetime2", mssqlColumnType(t, x, "lfs_lock", "created"))
// Inserting an ISO-formatted datetime must succeed even under a non-English
// locale, which is the failure the legacy DATETIME columns produced. The
// SET LANGUAGE and INSERT run in one Exec so they share a single connection.
_, err = x.Exec("SET LANGUAGE German; " +
"INSERT INTO [external_login_user] ([external_id], [login_source_id], [expires_at]) " +
"VALUES ('ext-id', 1, '2026-06-25 11:58:39')")
require.NoError(t, err)
_, err = x.Exec("SET LANGUAGE German; " +
"INSERT INTO [lfs_lock] ([created]) VALUES ('2026-06-25 11:58:39')")
require.NoError(t, err)
}
func mssqlColumnType(t *testing.T, x base.EngineMigration, table, column string) string {
t.Helper()
var dataType string
has, err := x.SQL("SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND COLUMN_NAME = ?", table, column).Get(&dataType)
require.NoError(t, err)
require.True(t, has)
return dataType
}
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"gitea.dev/modelmigration/base"
"gitea.dev/modules/timeutil"
"xorm.io/xorm"
)
func AddScopedWorkflowsSchema(x base.EngineMigration) error {
// Create the action_scoped_workflow_source table
type ScopedWorkflowConfig struct {
Required bool `json:"required"`
Patterns []string `json:"patterns"`
}
type ActionScopedWorkflowSource struct {
ID int64 `xorm:"pk autoincr"`
OwnerID int64 `xorm:"UNIQUE(owner_repo) NOT NULL DEFAULT 0"`
SourceRepoID int64 `xorm:"INDEX UNIQUE(owner_repo) NOT NULL DEFAULT 0"`
WorkflowConfigs map[string]*ScopedWorkflowConfig `xorm:"JSON TEXT 'workflow_configs'"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}
if err := x.Sync(new(ActionScopedWorkflowSource)); err != nil {
return err
}
// Add the columns that record where a run's workflow content came from
type ActionRun struct {
WorkflowRepoID int64 `xorm:"NOT NULL DEFAULT 0"`
WorkflowCommitSHA string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"`
IsScopedRun bool `xorm:"NOT NULL DEFAULT false"`
}
_, err := x.SyncWithOptions(xorm.SyncOptions{
IgnoreDropIndices: true,
IgnoreConstrains: true,
}, new(ActionRun))
return err
}