Add AI-friendly enhancements: runner capabilities, release archive, action compatibility
Some checks are pending
Build and Release / Lint and Test (push) Waiting to run
Build and Release / Build Binaries (amd64, darwin) (push) Blocked by required conditions
Build and Release / Build Binaries (amd64, linux) (push) Blocked by required conditions
Build and Release / Build Binaries (amd64, windows) (push) Blocked by required conditions
Build and Release / Build Binaries (arm64, darwin) (push) Blocked by required conditions
Build and Release / Build Binaries (arm64, linux) (push) Blocked by required conditions

- Add runner capability discovery API (v2) for AI tools to query before writing workflows
- Add release archive feature with filter toggle UI
- Add GitHub Actions compatibility layer with action aliasing
- Store runner capabilities JSON from act_runner Declare calls
- Add migrations for release archive and runner capabilities fields

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
David H. Friedel Jr. 2026-01-10 04:56:11 -05:00
parent 1ad0368230
commit fbd5da0acb
23 changed files with 819 additions and 23 deletions

3
go.mod
View File

@ -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

4
go.sum
View File

@ -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=

View File

@ -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
}

View File

@ -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
}

View File

@ -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))
}

View File

@ -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))
}

View File

@ -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": ""})

View File

@ -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]
}

View File

@ -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"`
}

View File

@ -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

View File

@ -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": "<strong>%d</strong> commits",

View File

@ -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)
}

View File

@ -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)

View File

@ -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))
}

191
routers/api/v2/actions.go Normal file
View File

@ -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
}

View File

@ -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
}
}
}

View File

@ -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")
}

View File

@ -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)

View File

@ -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
}

View File

@ -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())

View File

@ -2,6 +2,9 @@
* Release: the release
* IsLatest: boolean indicating whether this is the latest release, optional
*/}}
{{if .Release.IsArchived}}
<span class="ui grey label">{{ctx.Locale.Tr "repo.release.archived"}}</span>
{{end}}
{{if .IsLatest}}
<span class="ui green label">{{ctx.Locale.Tr "repo.release.latest"}}</span>
{{else if .Release.IsDraft}}

View File

@ -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)}}
</h4>
<div>
<div class="tw-flex tw-gap-2">
{{if and $.CanCreateRelease (not $.PageIsSingleTag)}}
{{if $release.IsArchived}}
<form action="{{$.RepoLink}}/releases/{{$release.ID}}/unarchive" method="post">
{{$.CsrfTokenHtml}}
<button class="muted tw-border-0 tw-bg-transparent tw-cursor-pointer" type="submit" data-tooltip-content="{{ctx.Locale.Tr "repo.release.unarchive"}}">
{{svg "octicon-archive" 16}}
</button>
</form>
{{else}}
<form action="{{$.RepoLink}}/releases/{{$release.ID}}/archive" method="post">
{{$.CsrfTokenHtml}}
<button class="muted tw-border-0 tw-bg-transparent tw-cursor-pointer" type="submit" data-tooltip-content="{{ctx.Locale.Tr "repo.release.archive"}}">
{{svg "octicon-archive" 16}}
</button>
</form>
{{end}}
<a class="muted" data-tooltip-content="{{ctx.Locale.Tr "repo.release.edit"}}" href="{{$.RepoLink}}/releases/edit/{{$release.TagName | PathEscapeSegments}}" rel="nofollow">
{{svg "octicon-pencil"}}
</a>

View File

@ -22,6 +22,14 @@
</a>
{{end}}
</div>
{{if and .PageIsReleaseList (not .PageIsSingleTag)}}
<div class="tw-flex tw-items-center tw-mb-2">
<div class="ui checkbox">
<input type="checkbox" id="show-archived" {{if .ShowArchived}}checked{{end}} onchange="window.location.href='{{.RepoLink}}/releases?archived=' + (this.checked ? 'true' : 'false')">
<label for="show-archived">{{ctx.Locale.Tr "repo.release.show_archived"}}</label>
</div>
</div>
{{end}}
<div class="divider"></div>
{{else if $canReadCode}}
{{/* if the "repo.releases" unit is disabled, only show the "commits / branches / tags" sub menu */}}