mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-04 06:25:32 +00:00
Adds a weekly and on-demand workflow that runs the cross-compiled tests on Windows Server 2025 Core in a [dockur/windows](https://github.com/dockur/windows) QEMU VM, and fixes the Windows bugs it found in host and UNC paths, env handling, shell quoting, post-task scripts, health checks, checkout, action symlinks, step summaries, Git client certificates and docker mode from a Windows host. Host-mode steps now emit UTF-8, `bash` falls back to Git's bash instead of the WSL launcher, the runner can run as a native Windows service, steps can keep background helpers with `RUNNER_TRACKING_ID`, and Windows arm64 binaries are released. Fixes: https://gitea.com/gitea/runner/issues/1339 Fixes: https://gitea.com/gitea/runner/issues/637 Fixes: https://gitea.com/gitea/runner/issues/1172 Fixes: https://gitea.com/gitea/runner/issues/347 Reviewed-on: https://gitea.com/gitea/runner/pulls/1348 Reviewed-by: bircni <[email protected]>
510 lines
15 KiB
Go
510 lines
15 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package git
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand/v2"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.com/gitea/runner/act/common"
|
|
)
|
|
|
|
var actionFetches sync.Map
|
|
|
|
var symlink = (*os.Root).Symlink
|
|
|
|
var symlinkFallback = runtime.GOOS == "windows"
|
|
|
|
const treeSizes = ".sizes" // beside the tree rather than in it, which is copied into job containers as it is
|
|
|
|
type treeFile struct {
|
|
Name []byte // not a string, which JSON would turn into UTF-8, as git allows any bytes but NUL
|
|
Size int64
|
|
}
|
|
|
|
type actionFetch struct {
|
|
done chan struct{}
|
|
dir, sha string
|
|
err error
|
|
retry bool // the leader was cancelled or panicked, so followers fetch themselves
|
|
}
|
|
|
|
var archiveRetryDelay = func(retryAfter string) time.Duration { // as actions/runner backs off
|
|
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
|
return min(max(time.Duration(seconds)*time.Second, 10*time.Second), 10*time.Minute)
|
|
}
|
|
return time.Duration(10+rand.IntN(21)) * time.Second
|
|
}
|
|
|
|
func FetchAction(ctx context.Context, input NewGitCloneExecutorInput) (string, string, error) { // input.Dir is one clone URL and ref's, holding a directory per commit
|
|
if !reflect.ValueOf(input).Comparable() {
|
|
return fetchAction(ctx, input)
|
|
}
|
|
for ctx.Err() == nil {
|
|
call := &actionFetch{done: make(chan struct{}), retry: true}
|
|
previous, loaded := actionFetches.LoadOrStore(input, call)
|
|
if !loaded {
|
|
func() {
|
|
defer func() { actionFetches.Delete(input); close(call.done) }()
|
|
call.dir, call.sha, call.err = fetchAction(ctx, input)
|
|
call.retry = ctx.Err() != nil
|
|
}()
|
|
if call.retry {
|
|
continue
|
|
}
|
|
return call.dir, call.sha, call.err
|
|
}
|
|
call, _ = previous.(*actionFetch)
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-call.done:
|
|
if !call.retry && ctx.Err() == nil {
|
|
return call.dir, call.sha, call.err
|
|
}
|
|
}
|
|
}
|
|
return "", "", ctx.Err()
|
|
}
|
|
|
|
func fetchAction(ctx context.Context, input NewGitCloneExecutorInput) (string, string, error) {
|
|
unlock, err := AcquireCloneLock(ctx, input.Dir)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer unlock()
|
|
if err := os.MkdirAll(input.Dir, 0o755); err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
tree := func(sha string) string { return filepath.Join(input.Dir, sha) }
|
|
record := filepath.Join(input.Dir, "ref")
|
|
data, _ := os.ReadFile(record)
|
|
sha, etag, _ := strings.Cut(string(data), "\n")
|
|
switch {
|
|
case !isFullObjectID(sha):
|
|
sha, etag = "", ""
|
|
case input.OfflineMode && exists(tree(sha)) && !exists(tree(sha)+treeSizes):
|
|
return tree(sha), sha, nil // from an older runner, with no sizes to check, so offline mode uses it as before
|
|
case !treeIntact(input.Dir, sha):
|
|
sha, etag = "", ""
|
|
case input.OfflineMode || isFullObjectID(input.Ref):
|
|
return tree(sha), sha, nil
|
|
}
|
|
|
|
newSha, newEtag, err := downloadArchive(ctx, input, sha, etag)
|
|
if err != nil && ctx.Err() == nil {
|
|
common.Logger(ctx).Debugf("Cloning %s@%s: %v", input.URL, input.Ref, err)
|
|
newEtag = ""
|
|
newSha, err = exportClone(ctx, input)
|
|
}
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if newSha != sha || newEtag != etag {
|
|
if err := os.WriteFile(record, []byte(newSha+"\n"+newEtag), 0o600); err != nil {
|
|
return "", "", err
|
|
}
|
|
}
|
|
return tree(newSha), newSha, nil
|
|
}
|
|
|
|
func downloadArchive(ctx context.Context, input NewGitCloneExecutorInput, sha, etag string) (string, string, error) {
|
|
rewritten, err := gitOutput(ctx, "", "ls-remote", "--get-url", input.URL)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
source, err := url.Parse(rewritten)
|
|
if err != nil || source.Scheme != "http" && source.Scheme != "https" || hexRefRegex.MatchString(input.Ref) && !isFullObjectID(input.Ref) { // only git tells a short commit from a branch
|
|
return "", "", errors.New("no archive")
|
|
}
|
|
archive := source.JoinPath("archive", input.Ref+".tar.gz")
|
|
if source.Host == "github.com" {
|
|
source.Host = "codeload.github.com"
|
|
archive = source.JoinPath("tar.gz", input.Ref)
|
|
}
|
|
client := &http.Client{Transport: input.Transport, Timeout: 5 * time.Minute, CheckRedirect: func(request *http.Request, via []*http.Request) error {
|
|
if len(via) >= 10 {
|
|
return errors.New("stopped after 10 redirects")
|
|
}
|
|
if request.URL.Host != via[0].URL.Host || request.URL.Scheme != via[0].URL.Scheme { // the token stays on its origin
|
|
request.Header.Del("Authorization")
|
|
}
|
|
return nil
|
|
}}
|
|
for attempt := 1; ; attempt++ {
|
|
newSha, newEtag, retryAfter, err := downloadArchiveOnce(ctx, client, archive.String(), input.Token, sha, etag, input.Dir)
|
|
if retryAfter == 0 || attempt == 3 || ctx.Err() != nil {
|
|
return newSha, newEtag, err
|
|
}
|
|
common.Logger(ctx).Warnf("Failed to download action '%s'. Error: %v. Back off %s before retry.", archive.Redacted(), err, retryAfter)
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", "", ctx.Err()
|
|
case <-time.After(retryAfter):
|
|
}
|
|
}
|
|
}
|
|
|
|
func downloadArchiveOnce(ctx context.Context, client *http.Client, archive, token, sha, etag, dir string) (string, string, time.Duration, error) {
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, archive, nil)
|
|
if err != nil {
|
|
return "", "", 0, err
|
|
}
|
|
if etag != "" {
|
|
request.Header.Set("If-None-Match", etag)
|
|
}
|
|
if token != "" {
|
|
request.SetBasicAuth("token", token)
|
|
}
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
var opErr *net.OpError
|
|
if errors.As(err, &opErr) && opErr.Op == "dial" {
|
|
return "", "", 0, err
|
|
}
|
|
return "", "", archiveRetryDelay(""), err
|
|
}
|
|
defer response.Body.Close()
|
|
switch status := response.StatusCode; {
|
|
case status == http.StatusNotModified && sha != "":
|
|
return sha, etag, 0, nil
|
|
case status == http.StatusOK:
|
|
newEtag := response.Header.Get("ETag")
|
|
gz, err := gzip.NewReader(response.Body)
|
|
if err == nil {
|
|
sha, err = extractTree(ctx, gz, dir, !strings.HasSuffix(newEtag, `-false"`)) // Gitea's mark of an unprefixed archive
|
|
}
|
|
var netErr net.Error
|
|
if errors.Is(err, io.ErrUnexpectedEOF) || errors.As(err, &netErr) {
|
|
return "", "", archiveRetryDelay(""), err
|
|
}
|
|
return sha, newEtag, 0, err
|
|
case status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status >= http.StatusInternalServerError:
|
|
return "", "", archiveRetryDelay(response.Header.Get("Retry-After")), errors.New(response.Status)
|
|
default:
|
|
return "", "", 0, errors.New(response.Status)
|
|
}
|
|
}
|
|
|
|
func exportClone(ctx context.Context, input NewGitCloneExecutorInput) (string, error) {
|
|
clone := input
|
|
clone.Dir = filepath.Join(input.Dir, "git")
|
|
sha, _, err := resolveClone(ctx, clone)
|
|
if err != nil || treeIntact(input.Dir, sha) {
|
|
return sha, err
|
|
}
|
|
archive, err := runGit(ctx, clone.Dir, nil, "archive", "--format=tar", sha)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
_, err = extractTree(ctx, bytes.NewReader(archive), input.Dir, false)
|
|
return sha, err
|
|
}
|
|
|
|
func extractTree(ctx context.Context, archive io.Reader, dir string, strip bool) (string, error) { // into dir/<commit of the pax header>, strip dropping the directory wrapping the files
|
|
temp, err := os.MkdirTemp(dir, "tmp-")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer os.RemoveAll(temp)
|
|
root, err := os.OpenRoot(temp)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer root.Close()
|
|
|
|
sha := ""
|
|
sizes := map[string]int64{}
|
|
links := map[string]string{}
|
|
var deferred []string
|
|
reader := tar.NewReader(archive)
|
|
for {
|
|
header, err := reader.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if header.Typeflag == tar.TypeXGlobalHeader {
|
|
if sha = header.PAXRecords["comment"]; isFullObjectID(sha) && treeIntact(dir, sha) {
|
|
return sha, nil
|
|
}
|
|
continue
|
|
}
|
|
name := header.Name
|
|
if strip {
|
|
_, name, _ = strings.Cut(name, "/")
|
|
}
|
|
if name = strings.TrimSuffix(name, "/"); name == "" {
|
|
continue
|
|
}
|
|
switch header.Typeflag {
|
|
case tar.TypeDir:
|
|
err = root.MkdirAll(name, 0o755)
|
|
case tar.TypeReg:
|
|
sizes[name], err = writeTreeFile(root, name, header.Mode, reader)
|
|
case tar.TypeSymlink:
|
|
links[filepath.Clean(name)] = header.Linkname
|
|
deferred = append(deferred, filepath.Clean(name))
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
if !isFullObjectID(sha) {
|
|
return "", errors.New("the archive names no commit")
|
|
}
|
|
linksByParent := map[string][]string{}
|
|
if symlinkFallback {
|
|
for name := range links {
|
|
linksByParent[filepath.Dir(name)] = append(linksByParent[filepath.Dir(name)], filepath.Base(name))
|
|
}
|
|
}
|
|
for requireTarget := symlinkFallback; len(deferred) > 0; {
|
|
unresolved := deferred[:0]
|
|
for _, name := range deferred {
|
|
if err := ctx.Err(); err != nil {
|
|
return "", err
|
|
}
|
|
if requireTarget {
|
|
if _, err := root.Stat(filepath.Dir(name) + string(filepath.Separator) + links[name]); err != nil {
|
|
unresolved = append(unresolved, name)
|
|
continue
|
|
}
|
|
}
|
|
if err := symlink(root, links[name], name); err == nil || !symlinkFallback {
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
continue
|
|
}
|
|
if err := copyTreeLink(ctx, root, name, links, linksByParent, sizes); err != nil {
|
|
return "", fmt.Errorf("materialize symlink %q: %w", filepath.ToSlash(name), err)
|
|
}
|
|
}
|
|
if len(unresolved) == len(deferred) {
|
|
requireTarget = false
|
|
}
|
|
deferred = unresolved
|
|
}
|
|
if err := root.Close(); err != nil {
|
|
return "", err
|
|
}
|
|
return sha, installTree(ctx, dir, sha, temp, sizes)
|
|
}
|
|
|
|
func resolveTreeLink(name string, links map[string]string) (string, bool) {
|
|
if !filepath.IsLocal(name) {
|
|
return "", false
|
|
}
|
|
parts := strings.Split(name, string(filepath.Separator))
|
|
var resolved []string
|
|
for count := 0; len(parts) > 0; {
|
|
part := parts[0]
|
|
parts = parts[1:]
|
|
switch part {
|
|
case "", ".":
|
|
continue
|
|
case "..":
|
|
if len(resolved) == 0 {
|
|
return "", false
|
|
}
|
|
resolved = resolved[:len(resolved)-1]
|
|
continue
|
|
}
|
|
target, ok := links[filepath.Join(append(resolved, part)...)]
|
|
if !ok {
|
|
resolved = append(resolved, part)
|
|
continue
|
|
}
|
|
count++
|
|
if target == "" || os.IsPathSeparator(target[0]) || filepath.VolumeName(target) != "" || count > 255 {
|
|
return "", false
|
|
}
|
|
parts = append(strings.Split(filepath.FromSlash(target), string(filepath.Separator)), parts...)
|
|
}
|
|
return filepath.Join(append([]string{"."}, resolved...)...), true
|
|
}
|
|
|
|
var (
|
|
errTreeLinkTarget = errors.New("unmaterializable symlink target")
|
|
errTreeLinkCycle = fmt.Errorf("cyclic %w", errTreeLinkTarget)
|
|
)
|
|
|
|
func copyTreeLink(ctx context.Context, root *os.Root, name string, links map[string]string, linksByParent map[string][]string, sizes map[string]int64) error {
|
|
writeLinkText := func(destination, target string) error { // as git writes links it cannot create
|
|
if err := root.RemoveAll(destination); err != nil {
|
|
return err
|
|
}
|
|
for written := range sizes {
|
|
if written == destination || strings.HasPrefix(written, destination+string(filepath.Separator)) {
|
|
delete(sizes, written)
|
|
}
|
|
}
|
|
size, err := writeTreeFile(root, destination, 0o644, strings.NewReader(target))
|
|
sizes[destination] = size
|
|
return err
|
|
}
|
|
active := map[string]bool{}
|
|
var copyTarget func(string, string) error
|
|
copyTarget = func(source, destination string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
source, ok := resolveTreeLink(source, links)
|
|
if !ok {
|
|
return errTreeLinkTarget
|
|
}
|
|
file, err := root.Open(source)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return errTreeLinkTarget
|
|
}
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode().IsRegular() {
|
|
sizes[destination], err = writeTreeFile(root, destination, int64(info.Mode().Perm()), file)
|
|
return err
|
|
}
|
|
if !info.IsDir() {
|
|
return errTreeLinkTarget
|
|
}
|
|
if active[source] {
|
|
return errTreeLinkCycle
|
|
}
|
|
active[source] = true
|
|
defer delete(active, source)
|
|
entries, err := file.ReadDir(-1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
children := map[string]bool{}
|
|
for _, entry := range entries {
|
|
children[entry.Name()] = true
|
|
}
|
|
for _, child := range linksByParent[source] {
|
|
children[child] = true
|
|
}
|
|
if err := root.MkdirAll(destination, info.Mode().Perm()); err != nil {
|
|
return err
|
|
}
|
|
for child := range children {
|
|
childSource, childDestination := filepath.Join(source, child), filepath.Join(destination, child)
|
|
err := copyTarget(childSource, childDestination)
|
|
if target, isLink := links[childSource]; isLink && errors.Is(err, errTreeLinkTarget) && !errors.Is(err, errTreeLinkCycle) {
|
|
err = writeLinkText(childDestination, target)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if err := copyTarget(name, name); !errors.Is(err, errTreeLinkTarget) {
|
|
return err
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
return writeLinkText(name, links[name])
|
|
}
|
|
|
|
var lockTree = AcquireCloneLock // replaced in tests to see installTree wait for a job holding the tree
|
|
|
|
// installTree moves temp into place as dir/sha, replacing a tree that failed treeIntact.
|
|
func installTree(ctx context.Context, dir, sha, temp string, sizes map[string]int64) error {
|
|
files := make([]treeFile, 0, len(sizes))
|
|
for name, size := range sizes {
|
|
files = append(files, treeFile{[]byte(name), size})
|
|
}
|
|
data, err := json.Marshal(files)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Written before the swap: it describes the commit, not a copy of it, so it holds for whichever tree is left.
|
|
if err := os.WriteFile(filepath.Join(dir, sha+treeSizes), data, 0o600); err != nil { // a torn record fails treeIntact, so it needs no rename
|
|
return err
|
|
}
|
|
tree := filepath.Join(dir, sha)
|
|
if !exists(tree) {
|
|
return os.Rename(temp, tree)
|
|
}
|
|
stale, err := os.MkdirTemp(dir, "tmp-")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(stale) // after the unlock, as no job reads the old tree once it is moved
|
|
unlock, err := lockTree(ctx, tree) // jobs copying the old tree hold it
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unlock()
|
|
if err := os.Rename(tree, filepath.Join(stale, sha)); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(temp, tree)
|
|
}
|
|
|
|
// treeIntact reports whether dir/sha still holds each file extracted into it at the size it was written.
|
|
// Only sizes are compared, not content: a stat per file is cheap enough for every job, hashing is not.
|
|
func treeIntact(dir, sha string) bool {
|
|
data, err := os.ReadFile(filepath.Join(dir, sha+treeSizes))
|
|
if err != nil || !exists(filepath.Join(dir, sha)) {
|
|
return false
|
|
}
|
|
var files []treeFile
|
|
if json.Unmarshal(data, &files) != nil {
|
|
return false
|
|
}
|
|
for _, file := range files {
|
|
info, err := os.Lstat(filepath.Join(dir, sha, filepath.FromSlash(string(file.Name))))
|
|
if err != nil || !info.Mode().IsRegular() || info.Size() != file.Size {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func writeTreeFile(root *os.Root, name string, mode int64, content io.Reader) (int64, error) {
|
|
perm := os.FileMode(0o644)
|
|
if mode&0o111 != 0 {
|
|
perm = 0o755
|
|
}
|
|
file, err := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, perm)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
n, err := io.Copy(file, content)
|
|
return n, errors.Join(err, file.Close())
|
|
}
|
|
|
|
func exists(name string) bool {
|
|
_, err := os.Stat(name)
|
|
return err == nil
|
|
}
|