actions_artifacts.go RawHistoryFolder
1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "bytes"
7 "io"
8 "os"
9 "strings"
10
11 "bnema.dev/ermite-actions/action"
12)
13
14// @actions/artifact v2 (upload-artifact and download-artifact v4 and later)
15// refuses to run when it believes the server is GitHub Enterprise Server:
16// isGhes() (toolkit packages/artifact/src/internal/shared/config.ts) reads
17// GITHUB_SERVER_URL only, and any host that is not github.com, *.ghe.com or
18// *.localhost is GHES. ermite's server URL is its own, so the node steps that
19// run that client get a server URL the client takes for GitHub, in the
20// environment of their process only. It is the one place where ermite changes
21// the environment of an action.
22const (
23 // artifactsServerURL is the GITHUB_SERVER_URL those steps get. isGhes()
24 // treats *.localhost as GitHub; the host is never resolved nor connected to
25 // (the client talks to ACTIONS_RESULTS_URL only).
26 artifactsServerURL = "https://ermite.ghe.localhost"
27 // ghesMessage is the error @actions/artifact v2 throws on GHES
28 // (packages/artifact/src/internal/shared/errors.ts:32): a script that holds
29 // it bundles the client.
30 ghesMessage = "@actions/artifact v2.0.0+, upload-artifact@v4+ and download-artifact@v4+ are not currently supported on GHES."
31 // sniffChunk is the size of the reads of the sniff.
32 sniffChunk = 64 << 10
33)
34
35// artifactRepos are the repository actions that use @actions/artifact v2, any
36// path inside them included (actions/upload-artifact/merge).
37var artifactRepos = []string{"actions/upload-artifact", "actions/download-artifact"}
38
39// setArtifactsServerURL decides the GITHUB_SERVER_URL of the process of a node
40// action step that runs script (main, pre or post): artifactsServerURL when the
41// job has the results service and the step is an artifact action or its script
42// bundles @actions/artifact v2, else the job's own value.
43func (j *job) setArtifactsServerURL(s *stepRun, script string) {
44 s.serverURL = ""
45 if !j.cfg.Results {
46 return
47 }
48 if !isArtifactAction(s.n) && !j.bundlesArtifactClient(script) {
49 return
50 }
51 s.serverURL = artifactsServerURL
52 j.debug(s.num, "GITHUB_SERVER_URL set to "+artifactsServerURL+" for the artifacts service")
53}
54
55// isArtifactAction reports whether the step uses one of artifactRepos, in any
56// case.
57func isArtifactAction(n *node) bool {
58 if n == nil || n.uses.Kind != action.UsesRepository {
59 return false
60 }
61 name := n.uses.Owner + "/" + n.uses.Repo
62 for _, r := range artifactRepos {
63 if strings.EqualFold(name, r) {
64 return true
65 }
66 }
67 return false
68}
69
70// bundlesArtifactClient reports whether the file holds ghesMessage. The answer
71// is kept per path for the job: the pre, main and post stages and other uses of
72// the action read it once. A file that cannot be read does not hold it.
73func (j *job) bundlesArtifactClient(script string) bool {
74 j.mu.Lock()
75 hit, ok := j.ghesSniff[script]
76 j.mu.Unlock()
77 if ok {
78 return hit
79 }
80 hit = fileContains(script, []byte(ghesMessage))
81 j.mu.Lock()
82 if j.ghesSniff == nil {
83 j.ghesSniff = map[string]bool{}
84 }
85 j.ghesSniff[script] = hit
86 j.mu.Unlock()
87 return hit
88}
89
90// fileContains reads the file in chunks and reports whether it holds needle;
91// len(needle)-1 bytes of a chunk are kept for the next, so a match across two
92// reads is found. A read error is a miss.
93func fileContains(name string, needle []byte) bool {
94 if len(needle) == 0 {
95 return true
96 }
97 f, err := os.Open(name)
98 if err != nil {
99 return false
100 }
101 defer func() { _ = f.Close() }()
102 keep := len(needle) - 1
103 buf := make([]byte, keep+sniffChunk)
104 have := 0
105 for {
106 n, err := f.Read(buf[have:])
107 have += n
108 if bytes.Contains(buf[:have], needle) {
109 return true
110 }
111 if err == io.EOF {
112 return false
113 }
114 if err != nil {
115 return false
116 }
117 if have > keep {
118 have = copy(buf, buf[have-keep:have])
119 }
120 }
121}