diff --git a/go.mod b/go.mod index c1852d6600..e8d406e327 100644 --- a/go.mod +++ b/go.mod @@ -312,6 +312,9 @@ replace github.com/nektos/act => gitea.com/gitea/act v0.261.7-0.20251003180512-a replace git.sr.ht/~mariusor/go-xsd-duration => gitea.com/gitea/go-xsd-duration v0.0.0-20220703122237-02e73435a078 +// Use GitCaddy fork with capability support +replace code.gitea.io/actions-proto-go => git.marketally.com/gitcaddy/actions-proto-go v0.5.0 + exclude github.com/gofrs/uuid v3.2.0+incompatible exclude github.com/gofrs/uuid v4.0.0+incompatible diff --git a/go.sum b/go.sum index 815ae22642..d2457d6e1e 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,6 @@ cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2k cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -code.gitea.io/actions-proto-go v0.4.1 h1:l0EYhjsgpUe/1VABo2eK7zcoNX2W44WOnb0MSLrKfls= -code.gitea.io/actions-proto-go v0.4.1/go.mod h1:mn7Wkqz6JbnTOHQpot3yDeHx+O5C9EGhMEE+htvHBas= code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI= code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE= code.gitea.io/sdk/gitea v0.22.0 h1:HCKq7bX/HQ85Nw7c/HAhWgRye+vBp5nQOE8Md1+9Ef0= @@ -31,6 +29,8 @@ dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +git.marketally.com/gitcaddy/actions-proto-go v0.5.0 h1:D2loMnqTXiaJL6TMfOOUJz4/3Vpv0AnMDSJVuiqMNrM= +git.marketally.com/gitcaddy/actions-proto-go v0.5.0/go.mod h1:li5RzZsj1sV8a0SXzXWsGNwv0dYw7Wj829AgloZqF5o= gitea.com/gitea/act v0.261.7-0.20251003180512-ac6e4b751763 h1:ohdxegvslDEllZmRNDqpKun6L4Oq81jNdEDtGgHEV2c= gitea.com/gitea/act v0.261.7-0.20251003180512-ac6e4b751763/go.mod h1:Pg5C9kQY1CEA3QjthjhlrqOC/QOT5NyWNjOjRHw23Ok= gitea.com/gitea/go-xsd-duration v0.0.0-20220703122237-02e73435a078 h1:BAFmdZpRW7zMQZQDClaCWobRj9uL1MR3MzpCVJvc5s4= diff --git a/models/actions/runner.go b/models/actions/runner.go index 84398b143b..8207329935 100644 --- a/models/actions/runner.go +++ b/models/actions/runner.go @@ -62,6 +62,8 @@ type ActionRunner struct { AgentLabels []string `xorm:"TEXT"` // Store if this is a runner that only ever get one single job assigned Ephemeral bool `xorm:"ephemeral NOT NULL DEFAULT false"` + // CapabilitiesJSON stores structured capability information for AI consumption + CapabilitiesJSON string `xorm:"TEXT"` Created timeutil.TimeStamp `xorm:"created"` Updated timeutil.TimeStamp `xorm:"updated"` @@ -394,3 +396,16 @@ func UpdateWrongRepoLevelRunners(ctx context.Context) (int64, error) { } return result.RowsAffected() } + +// GetRunnersOfRepo returns all runners available for a repository +// This includes repo-level, owner-level, and global runners +func GetRunnersOfRepo(ctx context.Context, repoID int64) ([]*ActionRunner, error) { + opts := FindRunnerOptions{ + RepoID: repoID, + WithAvailable: true, + } + + var runners []*ActionRunner + err := db.GetEngine(ctx).Where(opts.ToConds()).OrderBy(opts.ToOrders()).Find(&runners) + return runners, err +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 2a7efcf2ec..ab6379b060 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -403,6 +403,8 @@ func prepareMigrationTasks() []*migration { newMigration(326, "Add organization pinned repos tables", v1_26.AddOrgPinnedTables), newMigration(327, "Add Gitea Pages tables", v1_26.AddGiteaPagesTables), newMigration(328, "Add wiki index table for search", v1_26.AddWikiIndexTable), + newMigration(329, "Add release archive columns", v1_26.AddReleaseArchiveColumns), + newMigration(330, "Add runner capabilities column", v1_26.AddRunnerCapabilitiesColumn), } return preparedMigrations } diff --git a/models/migrations/v1_26/v329.go b/models/migrations/v1_26/v329.go new file mode 100644 index 0000000000..06276fcfc4 --- /dev/null +++ b/models/migrations/v1_26/v329.go @@ -0,0 +1,20 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +// AddReleaseArchiveColumns adds IsArchived and ArchivedUnix columns to the release table +func AddReleaseArchiveColumns(x *xorm.Engine) error { + type Release struct { + IsArchived bool `xorm:"NOT NULL DEFAULT false"` + ArchivedUnix timeutil.TimeStamp `xorm:"INDEX"` + } + + return x.Sync(new(Release)) +} diff --git a/models/migrations/v1_26/v330.go b/models/migrations/v1_26/v330.go new file mode 100644 index 0000000000..78459e5463 --- /dev/null +++ b/models/migrations/v1_26/v330.go @@ -0,0 +1,17 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "xorm.io/xorm" +) + +// AddRunnerCapabilitiesColumn adds CapabilitiesJSON column to action_runner table +func AddRunnerCapabilitiesColumn(x *xorm.Engine) error { + type ActionRunner struct { + CapabilitiesJSON string `xorm:"TEXT"` + } + + return x.Sync(new(ActionRunner)) +} diff --git a/models/repo/release.go b/models/repo/release.go index 67aa390e6d..ec3e28122c 100644 --- a/models/repo/release.go +++ b/models/repo/release.go @@ -85,6 +85,8 @@ type Release struct { IsDraft bool `xorm:"NOT NULL DEFAULT false"` IsPrerelease bool `xorm:"NOT NULL DEFAULT false"` IsTag bool `xorm:"NOT NULL DEFAULT false"` // will be true only if the record is a tag and has no related releases + IsArchived bool `xorm:"NOT NULL DEFAULT false"` + ArchivedUnix timeutil.TimeStamp `xorm:"INDEX"` Attachments []*Attachment `xorm:"-"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX"` } @@ -233,14 +235,16 @@ func GetReleaseForRepoByID(ctx context.Context, repoID, id int64) (*Release, err // FindReleasesOptions describes the conditions to Find releases type FindReleasesOptions struct { db.ListOptions - RepoID int64 - IncludeDrafts bool - IncludeTags bool - IsPreRelease optional.Option[bool] - IsDraft optional.Option[bool] - TagNames []string - HasSha1 optional.Option[bool] // useful to find draft releases which are created with existing tags - NamePattern optional.Option[string] + RepoID int64 + IncludeDrafts bool + IncludeTags bool + IncludeArchived bool + IsPreRelease optional.Option[bool] + IsDraft optional.Option[bool] + IsArchived optional.Option[bool] + TagNames []string + HasSha1 optional.Option[bool] // useful to find draft releases which are created with existing tags + NamePattern optional.Option[string] } func (opts FindReleasesOptions) ToConds() builder.Cond { @@ -252,6 +256,9 @@ func (opts FindReleasesOptions) ToConds() builder.Cond { if !opts.IncludeTags { cond = cond.And(builder.Eq{"is_tag": false}) } + if !opts.IncludeArchived { + cond = cond.And(builder.Eq{"is_archived": false}) + } if len(opts.TagNames) > 0 { cond = cond.And(builder.In("tag_name", opts.TagNames)) } @@ -261,6 +268,9 @@ func (opts FindReleasesOptions) ToConds() builder.Cond { if opts.IsDraft.Has() { cond = cond.And(builder.Eq{"is_draft": opts.IsDraft.Value()}) } + if opts.IsArchived.Has() { + cond = cond.And(builder.Eq{"is_archived": opts.IsArchived.Value()}) + } if opts.HasSha1.Has() { if opts.HasSha1.Value() { cond = cond.And(builder.Neq{"sha1": ""}) diff --git a/modules/actions/compatibility.go b/modules/actions/compatibility.go new file mode 100644 index 0000000000..72a73f01ea --- /dev/null +++ b/modules/actions/compatibility.go @@ -0,0 +1,137 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +// CompatibilityStatus represents the support status of an action +type CompatibilityStatus string + +const ( + CompatibilityFull CompatibilityStatus = "full" + CompatibilityPartial CompatibilityStatus = "partial" + CompatibilityNone CompatibilityStatus = "none" +) + +// ActionCompatibility represents compatibility information for a GitHub action +type ActionCompatibility struct { + // Action name (e.g., "actions/checkout") + Name string + // Supported versions + Versions []string + // Compatibility status + Status CompatibilityStatus + // Notes about compatibility + Notes string + // Suggested alternative if not fully compatible + Alternative string +} + +// BuiltinCompatibility contains known action compatibility information for Gitea Actions +// This is used by the capability discovery API to help AI tools write correct workflows +var BuiltinCompatibility = map[string]*ActionCompatibility{ + "actions/checkout": { + Name: "actions/checkout", + Versions: []string{"v2", "v3", "v4"}, + Status: CompatibilityFull, + Notes: "Fully compatible with Gitea Actions", + }, + "actions/setup-node": { + Name: "actions/setup-node", + Versions: []string{"v2", "v3", "v4"}, + Status: CompatibilityFull, + Notes: "Fully compatible with Gitea Actions", + }, + "actions/setup-go": { + Name: "actions/setup-go", + Versions: []string{"v3", "v4", "v5"}, + Status: CompatibilityFull, + Notes: "Fully compatible with Gitea Actions", + }, + "actions/setup-python": { + Name: "actions/setup-python", + Versions: []string{"v4", "v5"}, + Status: CompatibilityFull, + Notes: "Fully compatible with Gitea Actions", + }, + "actions/setup-java": { + Name: "actions/setup-java", + Versions: []string{"v3", "v4"}, + Status: CompatibilityFull, + Notes: "Fully compatible with Gitea Actions", + }, + "actions/setup-dotnet": { + Name: "actions/setup-dotnet", + Versions: []string{"v3", "v4"}, + Status: CompatibilityFull, + Notes: "Fully compatible with Gitea Actions", + }, + "actions/upload-artifact": { + Name: "actions/upload-artifact", + Versions: []string{"v2", "v3"}, + Status: CompatibilityPartial, + Notes: "v4 not supported on GHES-compatible runners. Use v3 or Gitea API for artifact upload.", + Alternative: "actions/upload-artifact@v3 or direct Gitea API upload", + }, + "actions/download-artifact": { + Name: "actions/download-artifact", + Versions: []string{"v2", "v3"}, + Status: CompatibilityPartial, + Notes: "v4 not supported on GHES-compatible runners. Use v3.", + Alternative: "actions/download-artifact@v3", + }, + "actions/cache": { + Name: "actions/cache", + Versions: []string{"v3", "v4"}, + Status: CompatibilityFull, + Notes: "Fully compatible with Gitea Actions", + }, + "actions/github-script": { + Name: "actions/github-script", + Versions: []string{"v6", "v7"}, + Status: CompatibilityPartial, + Notes: "GitHub API calls may not work. Use for basic scripting only.", + }, +} + +// UnsupportedFeatures lists features that are not supported in Gitea Actions +var UnsupportedFeatures = []string{ + "GitHub-hosted runners", + "Environments with protection rules", + "OIDC token authentication", + "Required workflows", + "Deployment branches", + "Reusable workflows from other repositories (limited)", + "actions/upload-artifact@v4", + "actions/download-artifact@v4", +} + +// IncompatibleActions maps action@version to error messages +var IncompatibleActions = map[string]string{ + "actions/upload-artifact@v4": "v4 not supported on Gitea/GHES-compatible runners. Use actions/upload-artifact@v3 or direct Gitea API upload.", + "actions/download-artifact@v4": "v4 not supported on Gitea/GHES-compatible runners. Use actions/download-artifact@v3.", +} + +// GetCompatibility returns compatibility information for an action +func GetCompatibility(actionName string) *ActionCompatibility { + return BuiltinCompatibility[actionName] +} + +// GetIncompatibilityMessage returns an error message if the action@version is incompatible +func GetIncompatibilityMessage(actionWithVersion string) string { + return IncompatibleActions[actionWithVersion] +} + +// IsActionCompatible checks if an action@version is compatible with Gitea Actions +func IsActionCompatible(actionWithVersion string) bool { + _, incompatible := IncompatibleActions[actionWithVersion] + return !incompatible +} + +// GetSuggestedAlternative returns a suggested alternative for an incompatible action +func GetSuggestedAlternative(actionWithVersion string) string { + alternatives := map[string]string{ + "actions/upload-artifact@v4": "uses: actions/upload-artifact@v3", + "actions/download-artifact@v4": "uses: actions/download-artifact@v3", + } + return alternatives[actionWithVersion] +} diff --git a/modules/structs/actions_capabilities.go b/modules/structs/actions_capabilities.go new file mode 100644 index 0000000000..caf2199c25 --- /dev/null +++ b/modules/structs/actions_capabilities.go @@ -0,0 +1,93 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +// RunnerCapability represents the detailed capabilities of a runner +type RunnerCapability struct { + OS string `json:"os"` + Arch string `json:"arch"` + Docker bool `json:"docker"` + DockerCompose bool `json:"docker_compose"` + ContainerRuntime string `json:"container_runtime,omitempty"` + Shell []string `json:"shell,omitempty"` + Tools map[string][]string `json:"tools,omitempty"` + Features *CapabilityFeatures `json:"features,omitempty"` + Limitations []string `json:"limitations,omitempty"` +} + +// CapabilityFeatures represents feature support flags +type CapabilityFeatures struct { + ArtifactsV4 bool `json:"artifacts_v4"` + Cache bool `json:"cache"` + Services bool `json:"services"` + CompositeActions bool `json:"composite_actions"` +} + +// ActionSupport represents version support for an action +type ActionSupport struct { + Versions []string `json:"versions"` + Notes string `json:"notes,omitempty"` +} + +// PlatformInfo represents Gitea platform capabilities +type PlatformInfo struct { + Type string `json:"type"` + Version string `json:"version"` + ActionsVersion string `json:"actions_version,omitempty"` + DefaultActionsURL string `json:"default_actions_url"` + SupportedActions map[string]ActionSupport `json:"supported_actions,omitempty"` + UnsupportedFeatures []string `json:"unsupported_features,omitempty"` +} + +// WorkflowHints provides hints for AI workflow generation +type WorkflowHints struct { + PreferredCheckout string `json:"preferred_checkout,omitempty"` + ArtifactUploadAlternative string `json:"artifact_upload_alternative,omitempty"` + SecretAccess string `json:"secret_access,omitempty"` +} + +// RunnerWithCapabilities represents a runner with its capabilities for API response +type RunnerWithCapabilities struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Labels []string `json:"labels"` + Capabilities *RunnerCapability `json:"capabilities,omitempty"` +} + +// ActionsCapabilitiesResponse is the response for the capabilities endpoint +type ActionsCapabilitiesResponse struct { + Runners []*RunnerWithCapabilities `json:"runners"` + Platform *PlatformInfo `json:"platform"` + WorkflowHints *WorkflowHints `json:"workflow_hints,omitempty"` +} + +// WorkflowValidationRequest is the request for workflow validation +type WorkflowValidationRequest struct { + Content string `json:"content" binding:"Required"` +} + +// WorkflowValidationWarning represents a validation warning +type WorkflowValidationWarning struct { + Line int `json:"line,omitempty"` + Action string `json:"action,omitempty"` + Severity string `json:"severity"` + Message string `json:"message"` + Suggestion string `json:"suggestion,omitempty"` +} + +// RunnerMatch represents job-to-runner matching result +type RunnerMatch struct { + Job string `json:"job"` + RunsOn []string `json:"runs_on"` + MatchedRunners []string `json:"matched_runners,omitempty"` + CapabilitiesMet bool `json:"capabilities_met"` +} + +// WorkflowValidationResponse is the response for workflow validation +type WorkflowValidationResponse struct { + Valid bool `json:"valid"` + Warnings []*WorkflowValidationWarning `json:"warnings,omitempty"` + RunnerMatch []*RunnerMatch `json:"runner_match,omitempty"` +} diff --git a/modules/structs/release.go b/modules/structs/release.go index 6a3e87ccbc..0b17f0f9f6 100644 --- a/modules/structs/release.go +++ b/modules/structs/release.go @@ -41,6 +41,10 @@ type Release struct { Publisher *User `json:"author"` // The files attached to the release Attachments []*Attachment `json:"assets"` + // Whether the release is archived + IsArchived bool `json:"archived"` + // swagger:strfmt date-time + ArchivedAt *time.Time `json:"archived_at,omitempty"` } // CreateReleaseOption options when creating a release diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index fdb54aeeb9..4547945d7d 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2613,6 +2613,10 @@ "repo.release.prerelease": "Pre-Release", "repo.release.stable": "Stable", "repo.release.latest": "Latest", + "repo.release.archived": "Archived", + "repo.release.archive": "Archive", + "repo.release.unarchive": "Unarchive", + "repo.release.show_archived": "Show archived releases", "repo.release.compare": "Compare", "repo.release.edit": "edit", "repo.release.ahead.commits": "%d commits", diff --git a/routers/api/actions/runner/runner.go b/routers/api/actions/runner/runner.go index 86bab4b340..176321b69c 100644 --- a/routers/api/actions/runner/runner.go +++ b/routers/api/actions/runner/runner.go @@ -117,7 +117,8 @@ func (s *Service) Declare( runner := GetRunner(ctx) runner.AgentLabels = req.Msg.Labels runner.Version = req.Msg.Version - if err := actions_model.UpdateRunner(ctx, runner, "agent_labels", "version"); err != nil { + runner.CapabilitiesJSON = req.Msg.CapabilitiesJson + if err := actions_model.UpdateRunner(ctx, runner, "agent_labels", "version", "capabilities_json"); err != nil { return nil, status.Errorf(codes.Internal, "update runner: %v", err) } diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index cb553df03d..c69733d49f 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1327,6 +1327,9 @@ func Routes() *web.Router { m.Combo("").Get(repo.GetRelease). Patch(reqToken(), reqRepoWriter(unit.TypeReleases), context.ReferencesGitRepo(), bind(api.EditReleaseOption{}), repo.EditRelease). Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteRelease) + m.Combo("/archive"). + Post(reqToken(), reqRepoWriter(unit.TypeReleases), repo.ArchiveRelease). + Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.UnarchiveRelease) m.Group("/assets", func() { m.Combo("").Get(repo.ListReleaseAttachments). Post(reqToken(), reqRepoWriter(unit.TypeReleases), repo.CreateReleaseAttachment) diff --git a/routers/api/v1/repo/release.go b/routers/api/v1/repo/release.go index 272b395dfb..33af68d5ea 100644 --- a/routers/api/v1/repo/release.go +++ b/routers/api/v1/repo/release.go @@ -136,6 +136,10 @@ func ListReleases(ctx *context.APIContext) { // in: query // description: filter (exclude / include) pre-releases // type: boolean + // - name: archived + // in: query + // description: filter archived releases (true=only archived, false=exclude archived, omit=all) + // type: boolean // - name: page // in: query // description: page number of results to return (1-based) @@ -151,13 +155,21 @@ func ListReleases(ctx *context.APIContext) { // "$ref": "#/responses/notFound" listOptions := utils.GetListOptions(ctx) + // By default, exclude archived releases unless explicitly requested + includeArchived := false + if ctx.FormOptionalBool("archived").Has() { + includeArchived = true + } + opts := repo_model.FindReleasesOptions{ - ListOptions: listOptions, - IncludeDrafts: ctx.Repo.AccessMode >= perm.AccessModeWrite || ctx.Repo.UnitAccessMode(unit.TypeReleases) >= perm.AccessModeWrite, - IncludeTags: false, - IsDraft: ctx.FormOptionalBool("draft"), - IsPreRelease: ctx.FormOptionalBool("pre-release"), - RepoID: ctx.Repo.Repository.ID, + ListOptions: listOptions, + IncludeDrafts: ctx.Repo.AccessMode >= perm.AccessModeWrite || ctx.Repo.UnitAccessMode(unit.TypeReleases) >= perm.AccessModeWrite, + IncludeTags: false, + IncludeArchived: includeArchived, + IsDraft: ctx.FormOptionalBool("draft"), + IsPreRelease: ctx.FormOptionalBool("pre-release"), + IsArchived: ctx.FormOptionalBool("archived"), + RepoID: ctx.Repo.Repository.ID, } releases, err := db.Find[repo_model.Release](ctx, opts) @@ -419,3 +431,109 @@ func DeleteRelease(ctx *context.APIContext) { } ctx.Status(http.StatusNoContent) } + +// ArchiveRelease archives a release +func ArchiveRelease(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/releases/{id}/archive repository repoArchiveRelease + // --- + // summary: Archive a release + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the release to archive + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Release" + // "404": + // "$ref": "#/responses/notFound" + + id := ctx.PathParamInt64("id") + release, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, id) + if err != nil && !repo_model.IsErrReleaseNotExist(err) { + ctx.APIErrorInternal(err) + return + } + if err != nil && repo_model.IsErrReleaseNotExist(err) || release.IsTag { + ctx.APIErrorNotFound() + return + } + + if err := release_service.ArchiveRelease(ctx, release); err != nil { + ctx.APIErrorInternal(err) + return + } + + if err := release.LoadAttributes(ctx); err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, release)) +} + +// UnarchiveRelease unarchives a release +func UnarchiveRelease(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/releases/{id}/archive repository repoUnarchiveRelease + // --- + // summary: Unarchive a release + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the release to unarchive + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Release" + // "404": + // "$ref": "#/responses/notFound" + + id := ctx.PathParamInt64("id") + release, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, id) + if err != nil && !repo_model.IsErrReleaseNotExist(err) { + ctx.APIErrorInternal(err) + return + } + if err != nil && repo_model.IsErrReleaseNotExist(err) || release.IsTag { + ctx.APIErrorNotFound() + return + } + + if err := release_service.UnarchiveRelease(ctx, release); err != nil { + ctx.APIErrorInternal(err) + return + } + + if err := release.LoadAttributes(ctx); err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, release)) +} diff --git a/routers/api/v2/actions.go b/routers/api/v2/actions.go new file mode 100644 index 0000000000..4b96abd059 --- /dev/null +++ b/routers/api/v2/actions.go @@ -0,0 +1,191 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v2 + +import ( + "encoding/json" + "time" + + "code.gitea.io/gitea/modules/actions" + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" +) + +// getSupportedActions converts the compatibility module data to API format +func getSupportedActions() map[string]api.ActionSupport { + result := make(map[string]api.ActionSupport) + for name, compat := range actions.BuiltinCompatibility { + result[name] = api.ActionSupport{ + Versions: compat.Versions, + Notes: compat.Notes, + } + } + return result +} + +// GetActionsCapabilities returns structured capability information for AI consumption +// @Summary Get runner capabilities for AI workflow generation +// @Description Returns detailed runner capabilities, platform info, and action compatibility +// @Tags actions +// @Accept json +// @Produce json +// @Param owner path string true "owner of the repo" +// @Param repo path string true "name of the repo" +// @Success 200 {object} api.ActionsCapabilitiesResponse +// @Router /repos/{owner}/{repo}/actions/runners/capabilities [get] +func GetActionsCapabilities(ctx *context.APIContext) { + repo := ctx.Repo.Repository + + // Get runners available for this repository + runners, err := actions_model.GetRunnersOfRepo(ctx, repo.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + // Build response + response := &api.ActionsCapabilitiesResponse{ + Runners: make([]*api.RunnerWithCapabilities, 0, len(runners)), + Platform: &api.PlatformInfo{ + Type: "gitea", + Version: setting.AppVer, + DefaultActionsURL: setting.Actions.DefaultActionsURL.URL(), + SupportedActions: getSupportedActions(), + UnsupportedFeatures: actions.UnsupportedFeatures, + }, + WorkflowHints: &api.WorkflowHints{ + PreferredCheckout: "actions/checkout@v4", + ArtifactUploadAlternative: "Use Gitea API: curl -X POST $GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/{id}/assets", + SecretAccess: "Use ${{ secrets.NAME }} syntax", + }, + } + + // Process each runner + for _, runner := range runners { + status := "offline" + if runner.LastOnline.AsTime().Add(actions_model.RunnerOfflineTime).After(time.Now()) { + status = "online" + } + + runnerResp := &api.RunnerWithCapabilities{ + ID: runner.ID, + Name: runner.Name, + Status: status, + Labels: runner.AgentLabels, + } + + // Parse capabilities JSON if available + if runner.CapabilitiesJSON != "" { + var cap api.RunnerCapability + if err := json.Unmarshal([]byte(runner.CapabilitiesJSON), &cap); err == nil { + runnerResp.Capabilities = &cap + } + } + + // If no capabilities, infer from labels + if runnerResp.Capabilities == nil { + runnerResp.Capabilities = inferCapabilitiesFromLabels(runner.AgentLabels) + } + + response.Runners = append(response.Runners, runnerResp) + } + + ctx.JSON(200, response) +} + +// inferCapabilitiesFromLabels attempts to infer capabilities from runner labels +func inferCapabilitiesFromLabels(labels []string) *api.RunnerCapability { + cap := &api.RunnerCapability{ + Limitations: []string{ + "Capabilities inferred from labels - may not be accurate", + "actions/upload-artifact@v4 not supported (use v3 or direct API upload)", + }, + } + + for _, label := range labels { + switch label { + case "ubuntu-latest", "ubuntu-22.04", "ubuntu-20.04": + cap.OS = "linux" + cap.Shell = []string{"bash", "sh"} + case "windows-latest", "windows-2022", "windows-2019": + cap.OS = "windows" + cap.Shell = []string{"pwsh", "powershell", "cmd"} + case "macos-latest", "macos-13", "macos-12": + cap.OS = "darwin" + cap.Shell = []string{"bash", "sh", "zsh"} + case "linux": + cap.OS = "linux" + case "x64", "amd64": + cap.Arch = "amd64" + case "arm64", "aarch64": + cap.Arch = "arm64" + case "docker": + cap.Docker = true + cap.ContainerRuntime = "docker" + } + } + + return cap +} + +// ValidateWorkflow validates a workflow YAML and returns compatibility warnings +// @Summary Validate a workflow for compatibility +// @Description Parses workflow YAML and returns warnings about unsupported features +// @Tags actions +// @Accept json +// @Produce json +// @Param owner path string true "owner of the repo" +// @Param repo path string true "name of the repo" +// @Param body body api.WorkflowValidationRequest true "Workflow content" +// @Success 200 {object} api.WorkflowValidationResponse +// @Router /repos/{owner}/{repo}/actions/workflows/validate [post] +func ValidateWorkflow(ctx *context.APIContext) { + form := web.GetForm(ctx).(*api.WorkflowValidationRequest) + + response := &api.WorkflowValidationResponse{ + Valid: true, + Warnings: make([]*api.WorkflowValidationWarning, 0), + } + + // Check for known incompatible actions using the compatibility module + for action, message := range actions.IncompatibleActions { + if containsAction(form.Content, action) { + response.Warnings = append(response.Warnings, &api.WorkflowValidationWarning{ + Action: action, + Severity: "error", + Message: message, + Suggestion: actions.GetSuggestedAlternative(action), + }) + } + } + + if len(response.Warnings) > 0 { + response.Valid = false + } + + ctx.JSON(200, response) +} + +// containsAction checks if workflow content contains a specific action +func containsAction(content, action string) bool { + // Simple string search - could be enhanced with YAML parsing + return len(content) > 0 && len(action) > 0 && + (contains(content, "uses: "+action) || contains(content, "uses: \""+action+"\"")) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstr(s, substr)) +} + +func containsSubstr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/routers/api/v2/api.go b/routers/api/v2/api.go index 1d78ebf6a6..063c32a1da 100644 --- a/routers/api/v2/api.go +++ b/routers/api/v2/api.go @@ -22,8 +22,12 @@ package v2 import ( "net/http" + "strings" + access_model "code.gitea.io/gitea/models/perm/access" auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" apierrors "code.gitea.io/gitea/modules/errors" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/idempotency" @@ -132,6 +136,12 @@ func Routes() *web.Router { m.Delete("/pages/{pageName}", DeleteWikiPageV2) }, reqToken()) }) + + // Actions v2 API - AI-friendly runner capability discovery + m.Group("/repos/{owner}/{repo}/actions", func() { + m.Get("/runners/capabilities", repoAssignment(), GetActionsCapabilities) + m.Post("/workflows/validate", repoAssignment(), reqToken(), web.Bind(api.WorkflowValidationRequest{}), ValidateWorkflow) + }) }) return m @@ -188,3 +198,59 @@ func reqToken() func(ctx *context.APIContext) { } } } + +// repoAssignment loads the repository from path parameters +func repoAssignment() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + ownerName := ctx.PathParam("owner") + repoName := ctx.PathParam("repo") + + var ( + owner *user_model.User + err error + ) + + // Check if the user is the same as the repository owner + if ctx.IsSigned && strings.EqualFold(ctx.Doer.LowerName, ownerName) { + owner = ctx.Doer + } else { + owner, err = user_model.GetUserByName(ctx, ownerName) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.APIErrorNotFound("GetUserByName", err) + } else { + ctx.APIErrorInternal(err) + } + return + } + } + ctx.Repo.Owner = owner + ctx.ContextUser = owner + + // Get repository + repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, repoName) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + ctx.APIErrorNotFound("GetRepositoryByName", err) + } else { + ctx.APIErrorInternal(err) + } + return + } + + repo.Owner = owner + ctx.Repo.Repository = repo + + // Get permissions + ctx.Repo.Permission, err = access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + if !ctx.Repo.Permission.HasAnyUnitAccessOrPublicAccess() { + ctx.APIErrorNotFound("HasAnyUnitAccessOrPublicAccess") + return + } + } +} diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index 1b36dc4d44..9019455b23 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -168,12 +168,22 @@ func Releases(ctx *context.Context) { writeAccess := ctx.Repo.CanWrite(unit.TypeReleases) ctx.Data["CanCreateRelease"] = writeAccess && !ctx.Repo.Repository.IsArchived - releases, err := getReleaseInfos(ctx, &repo_model.FindReleasesOptions{ + // Handle archived filter + showArchived := ctx.FormString("archived") == "true" + ctx.Data["ShowArchived"] = showArchived + + findOpts := &repo_model.FindReleasesOptions{ ListOptions: listOptions, // only show draft releases for users who can write, read-only users shouldn't see draft releases. - IncludeDrafts: writeAccess, - RepoID: ctx.Repo.Repository.ID, - }) + IncludeDrafts: writeAccess, + RepoID: ctx.Repo.Repository.ID, + IncludeArchived: showArchived, + } + if !showArchived { + findOpts.IsArchived = optional.Some(false) + } + + releases, err := getReleaseInfos(ctx, findOpts) if err != nil { ctx.ServerError("getReleaseInfos", err) return @@ -701,3 +711,47 @@ func deleteReleaseOrTag(ctx *context.Context, isDelTag bool) { redirect() } + +// ArchiveReleasePost archives a release +func ArchiveReleasePost(ctx *context.Context) { + releaseID := ctx.PathParamInt64("id") + rel, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, releaseID) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + ctx.NotFound(err) + } else { + ctx.ServerError("GetReleaseForRepoByID", err) + } + return + } + + if err := release_service.ArchiveRelease(ctx, rel); err != nil { + ctx.ServerError("ArchiveRelease", err) + return + } + + ctx.Flash.Success(string(ctx.Tr("repo.release.archive")) + ": " + rel.Title) + ctx.Redirect(ctx.Repo.RepoLink + "/releases") +} + +// UnarchiveReleasePost unarchives a release +func UnarchiveReleasePost(ctx *context.Context) { + releaseID := ctx.PathParamInt64("id") + rel, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, releaseID) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + ctx.NotFound(err) + } else { + ctx.ServerError("GetReleaseForRepoByID", err) + } + return + } + + if err := release_service.UnarchiveRelease(ctx, rel); err != nil { + ctx.ServerError("UnarchiveRelease", err) + return + } + + ctx.Flash.Success(string(ctx.Tr("repo.release.unarchive")) + ": " + rel.Title) + ctx.Redirect(ctx.Repo.RepoLink + "/releases?archived=true") +} diff --git a/routers/web/web.go b/routers/web/web.go index 58c9072c1b..67368873db 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1416,6 +1416,8 @@ func registerWebRoutes(m *web.Router) { m.Post("/delete", repo.DeleteRelease) m.Post("/attachments", repo.UploadReleaseAttachment) m.Post("/attachments/remove", repo.DeleteAttachment) + m.Post("/{id}/archive", repo.ArchiveReleasePost) + m.Post("/{id}/unarchive", repo.UnarchiveReleasePost) }, reqSignIn, context.RepoMustNotBeArchived(), reqRepoReleaseWriter) m.Group("/releases", func() { m.Get("/edit/*", repo.EditRelease) diff --git a/services/convert/release.go b/services/convert/release.go index bfff53e62f..244d97418a 100644 --- a/services/convert/release.go +++ b/services/convert/release.go @@ -12,7 +12,7 @@ import ( // ToAPIRelease convert a repo_model.Release to api.Release func ToAPIRelease(ctx context.Context, repo *repo_model.Repository, r *repo_model.Release) *api.Release { - return &api.Release{ + rel := &api.Release{ ID: r.ID, TagName: r.TagName, Target: r.Target, @@ -29,5 +29,11 @@ func ToAPIRelease(ctx context.Context, repo *repo_model.Repository, r *repo_mode PublishedAt: r.CreatedUnix.AsTime(), Publisher: ToUser(ctx, r.Publisher, nil), Attachments: ToAPIAttachments(repo, r.Attachments), + IsArchived: r.IsArchived, } + if r.IsArchived && r.ArchivedUnix > 0 { + archivedAt := r.ArchivedUnix.AsTime() + rel.ArchivedAt = &archivedAt + } + return rel } diff --git a/services/release/release.go b/services/release/release.go index a0d3736b44..61088e7a17 100644 --- a/services/release/release.go +++ b/services/release/release.go @@ -422,6 +422,30 @@ func DeleteReleaseByID(ctx context.Context, repo *repo_model.Repository, rel *re return nil } +// ArchiveRelease archives a release by setting IsArchived to true +func ArchiveRelease(ctx context.Context, rel *repo_model.Release) error { + if rel.IsArchived { + return nil // Already archived + } + + rel.IsArchived = true + rel.ArchivedUnix = timeutil.TimeStampNow() + + return repo_model.UpdateRelease(ctx, rel) +} + +// UnarchiveRelease unarchives a release by setting IsArchived to false +func UnarchiveRelease(ctx context.Context, rel *repo_model.Release) error { + if !rel.IsArchived { + return nil // Not archived + } + + rel.IsArchived = false + rel.ArchivedUnix = 0 + + return repo_model.UpdateRelease(ctx, rel) +} + // Init start release service func Init() error { return initTagSyncQueue(graceful.GetManager().ShutdownContext()) diff --git a/templates/repo/release/label.tmpl b/templates/repo/release/label.tmpl index 2381a15351..08bcb5da79 100644 --- a/templates/repo/release/label.tmpl +++ b/templates/repo/release/label.tmpl @@ -2,6 +2,9 @@ * Release: the release * IsLatest: boolean indicating whether this is the latest release, optional */}} +{{if .Release.IsArchived}} + {{ctx.Locale.Tr "repo.release.archived"}} +{{end}} {{if .IsLatest}} {{ctx.Locale.Tr "repo.release.latest"}} {{else if .Release.IsDraft}} diff --git a/templates/repo/release/list.tmpl b/templates/repo/release/list.tmpl index b7a60a44ed..5b7f7bbf47 100644 --- a/templates/repo/release/list.tmpl +++ b/templates/repo/release/list.tmpl @@ -36,8 +36,23 @@ {{template "repo/commit_statuses" dict "Status" $info.CommitStatus "Statuses" $info.CommitStatuses "AdditionalClasses" "tw-flex"}} {{template "repo/release/label" (dict "Release" $release)}} -