detached 3e5ad18

Merge pull request 'feat(executor): run node, docker, composite and local actions (CI-2 phase F)' (#12) from feat/ci2-actions into main

commit
3e5ad180cbb06a437a581eae9fb47be335b20f79
author
bnema · · 2026-10-14 03:39 UTC
merge of
86ddb5f + e707c63 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
44 files · +6769 -273
M go.mod +1 -1
@@ -3,7 +3,7 @@ module bnema.dev/ermite-runner
33 go 1.27.1
44
55 require (
6- bnema.dev/ermite-actions v0.0.0-20261013040121-5c0e25d50362
6+ bnema.dev/ermite-actions v0.1.1-0.20261013141203-3425eb2aa0ab
77 bnema.dev/ermite-api v0.1.1-0.20261013091642-49ac5fd7a78a
88 connectrpc.com/connect v1.21.0
99 github.com/bnema/zerowrap v1.4.1
M go.sum +2 -2 generated file: collapsed
M internal/execstream/exec.proto +37 -0
@@ -27,6 +27,8 @@ message Frame {
2727 JobResult job_result = 9; // executor -> host, before Exit
2828 StepAnnotations step_annotations = 10; // executor -> host, before the StepCompleted of the step
2929 StepSummary step_summary = 11; // executor -> host, before the StepCompleted of the step
30+ ResolveRequest resolve_request = 12; // executor -> host
31+ ResolveResponse resolve_response = 13; // host -> executor, answers a ResolveRequest
3032 }
3133 }
3234
@@ -192,3 +194,38 @@ message JobResult {
192194 string error = 3;
193195 bool runner_shutdown = 4;
194196 }
197+
198+// ResolveRequest asks the host to resolve actions with RunnerService.
199+// ResolveActions: the job token stays in the host, so the executor cannot call
200+// it. The executor sends one per level of composite actions (the runner's own
201+// "one more round per level") and waits for the ResolveResponse with the same id.
202+message ResolveRequest {
203+ int64 id = 1;
204+ repeated string uses = 2; // "owner/repo[/path]@ref", at most 100
205+}
206+
207+// ResolveResponse answers a ResolveRequest. error is set when the call itself
208+// failed (the executor fails the job); actions has one entry per uses otherwise.
209+message ResolveResponse {
210+ int64 id = 1;
211+ string error = 2;
212+ repeated ResolvedAction actions = 3;
213+}
214+
215+// ResolvedAction mirrors ermite.ResolvedAction (the token is a secret: the
216+// executor masks it).
217+message ResolvedAction {
218+ string ref = 1;
219+ string name_with_owner = 2;
220+ string path = 3;
221+ string resolved_sha = 4;
222+ string tarball_url = 5;
223+ string token = 6;
224+ string source = 7;
225+ string error = 8;
226+ // auth_origin (scheme://host[:port], as the sandbox reaches it) is the only
227+ // origin the token may be sent to; empty means the token is never sent. The
228+ // host sets it for archives served by the ermite server, from its own
229+ // configuration, never from the URL alone.
230+ string auth_origin = 9;
231+}
M internal/execstream/execpb/exec.pb.go +315 -22
@@ -257,6 +257,8 @@ type Frame struct {
257257 // *Frame_JobResult
258258 // *Frame_StepAnnotations
259259 // *Frame_StepSummary
260+ // *Frame_ResolveRequest
261+ // *Frame_ResolveResponse
260262 Body isFrame_Body `protobuf_oneof:"body"`
261263 unknownFields protoimpl.UnknownFields
262264 sizeCache protoimpl.SizeCache
@@ -398,6 +400,24 @@ func (x *Frame) GetStepSummary() *StepSummary {
398400 return nil
399401 }
400402
403+func (x *Frame) GetResolveRequest() *ResolveRequest {
404+ if x != nil {
405+ if x, ok := x.Body.(*Frame_ResolveRequest); ok {
406+ return x.ResolveRequest
407+ }
408+ }
409+ return nil
410+}
411+
412+func (x *Frame) GetResolveResponse() *ResolveResponse {
413+ if x != nil {
414+ if x, ok := x.Body.(*Frame_ResolveResponse); ok {
415+ return x.ResolveResponse
416+ }
417+ }
418+ return nil
419+}
420+
401421 type isFrame_Body interface {
402422 isFrame_Body()
403423 }
@@ -446,6 +466,14 @@ type Frame_StepSummary struct {
446466 StepSummary *StepSummary `protobuf:"bytes,11,opt,name=step_summary,json=stepSummary,proto3,oneof"` // executor -> host, before the StepCompleted of the step
447467 }
448468
469+type Frame_ResolveRequest struct {
470+ ResolveRequest *ResolveRequest `protobuf:"bytes,12,opt,name=resolve_request,json=resolveRequest,proto3,oneof"` // executor -> host
471+}
472+
473+type Frame_ResolveResponse struct {
474+ ResolveResponse *ResolveResponse `protobuf:"bytes,13,opt,name=resolve_response,json=resolveResponse,proto3,oneof"` // host -> executor, answers a ResolveRequest
475+}
476+
449477 func (*Frame_Hello) isFrame_Body() {}
450478
451479 func (*Frame_Run) isFrame_Body() {}
@@ -468,6 +496,10 @@ func (*Frame_StepAnnotations) isFrame_Body() {}
468496
469497 func (*Frame_StepSummary) isFrame_Body() {}
470498
499+func (*Frame_ResolveRequest) isFrame_Body() {}
500+
501+func (*Frame_ResolveResponse) isFrame_Body() {}
502+
471503 type Hello struct {
472504 state protoimpl.MessageState `protogen:"open.v1"`
473505 Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` // runner version of the executor
@@ -1416,12 +1448,244 @@ func (x *JobResult) GetRunnerShutdown() bool {
14161448 return false
14171449 }
14181450
1451+// ResolveRequest asks the host to resolve actions with RunnerService.
1452+// ResolveActions: the job token stays in the host, so the executor cannot call
1453+// it. The executor sends one per level of composite actions (the runner's own
1454+// "one more round per level") and waits for the ResolveResponse with the same id.
1455+type ResolveRequest struct {
1456+ state protoimpl.MessageState `protogen:"open.v1"`
1457+ Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
1458+ Uses []string `protobuf:"bytes,2,rep,name=uses,proto3" json:"uses,omitempty"` // "owner/repo[/path]@ref", at most 100
1459+ unknownFields protoimpl.UnknownFields
1460+ sizeCache protoimpl.SizeCache
1461+}
1462+
1463+func (x *ResolveRequest) Reset() {
1464+ *x = ResolveRequest{}
1465+ mi := &file_exec_proto_msgTypes[14]
1466+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1467+ ms.StoreMessageInfo(mi)
1468+}
1469+
1470+func (x *ResolveRequest) String() string {
1471+ return protoimpl.X.MessageStringOf(x)
1472+}
1473+
1474+func (*ResolveRequest) ProtoMessage() {}
1475+
1476+func (x *ResolveRequest) ProtoReflect() protoreflect.Message {
1477+ mi := &file_exec_proto_msgTypes[14]
1478+ if x != nil {
1479+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1480+ if ms.LoadMessageInfo() == nil {
1481+ ms.StoreMessageInfo(mi)
1482+ }
1483+ return ms
1484+ }
1485+ return mi.MessageOf(x)
1486+}
1487+
1488+// Deprecated: Use ResolveRequest.ProtoReflect.Descriptor instead.
1489+func (*ResolveRequest) Descriptor() ([]byte, []int) {
1490+ return file_exec_proto_rawDescGZIP(), []int{14}
1491+}
1492+
1493+func (x *ResolveRequest) GetId() int64 {
1494+ if x != nil {
1495+ return x.Id
1496+ }
1497+ return 0
1498+}
1499+
1500+func (x *ResolveRequest) GetUses() []string {
1501+ if x != nil {
1502+ return x.Uses
1503+ }
1504+ return nil
1505+}
1506+
1507+// ResolveResponse answers a ResolveRequest. error is set when the call itself
1508+// failed (the executor fails the job); actions has one entry per uses otherwise.
1509+type ResolveResponse struct {
1510+ state protoimpl.MessageState `protogen:"open.v1"`
1511+ Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
1512+ Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
1513+ Actions []*ResolvedAction `protobuf:"bytes,3,rep,name=actions,proto3" json:"actions,omitempty"`
1514+ unknownFields protoimpl.UnknownFields
1515+ sizeCache protoimpl.SizeCache
1516+}
1517+
1518+func (x *ResolveResponse) Reset() {
1519+ *x = ResolveResponse{}
1520+ mi := &file_exec_proto_msgTypes[15]
1521+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1522+ ms.StoreMessageInfo(mi)
1523+}
1524+
1525+func (x *ResolveResponse) String() string {
1526+ return protoimpl.X.MessageStringOf(x)
1527+}
1528+
1529+func (*ResolveResponse) ProtoMessage() {}
1530+
1531+func (x *ResolveResponse) ProtoReflect() protoreflect.Message {
1532+ mi := &file_exec_proto_msgTypes[15]
1533+ if x != nil {
1534+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1535+ if ms.LoadMessageInfo() == nil {
1536+ ms.StoreMessageInfo(mi)
1537+ }
1538+ return ms
1539+ }
1540+ return mi.MessageOf(x)
1541+}
1542+
1543+// Deprecated: Use ResolveResponse.ProtoReflect.Descriptor instead.
1544+func (*ResolveResponse) Descriptor() ([]byte, []int) {
1545+ return file_exec_proto_rawDescGZIP(), []int{15}
1546+}
1547+
1548+func (x *ResolveResponse) GetId() int64 {
1549+ if x != nil {
1550+ return x.Id
1551+ }
1552+ return 0
1553+}
1554+
1555+func (x *ResolveResponse) GetError() string {
1556+ if x != nil {
1557+ return x.Error
1558+ }
1559+ return ""
1560+}
1561+
1562+func (x *ResolveResponse) GetActions() []*ResolvedAction {
1563+ if x != nil {
1564+ return x.Actions
1565+ }
1566+ return nil
1567+}
1568+
1569+// ResolvedAction mirrors ermite.ResolvedAction (the token is a secret: the
1570+// executor masks it).
1571+type ResolvedAction struct {
1572+ state protoimpl.MessageState `protogen:"open.v1"`
1573+ Ref string `protobuf:"bytes,1,opt,name=ref,proto3" json:"ref,omitempty"`
1574+ NameWithOwner string `protobuf:"bytes,2,opt,name=name_with_owner,json=nameWithOwner,proto3" json:"name_with_owner,omitempty"`
1575+ Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"`
1576+ ResolvedSha string `protobuf:"bytes,4,opt,name=resolved_sha,json=resolvedSha,proto3" json:"resolved_sha,omitempty"`
1577+ TarballUrl string `protobuf:"bytes,5,opt,name=tarball_url,json=tarballUrl,proto3" json:"tarball_url,omitempty"`
1578+ Token string `protobuf:"bytes,6,opt,name=token,proto3" json:"token,omitempty"`
1579+ Source string `protobuf:"bytes,7,opt,name=source,proto3" json:"source,omitempty"`
1580+ Error string `protobuf:"bytes,8,opt,name=error,proto3" json:"error,omitempty"`
1581+ // auth_origin (scheme://host[:port], as the sandbox reaches it) is the only
1582+ // origin the token may be sent to; empty means the token is never sent. The
1583+ // host sets it for archives served by the ermite server, from its own
1584+ // configuration, never from the URL alone.
1585+ AuthOrigin string `protobuf:"bytes,9,opt,name=auth_origin,json=authOrigin,proto3" json:"auth_origin,omitempty"`
1586+ unknownFields protoimpl.UnknownFields
1587+ sizeCache protoimpl.SizeCache
1588+}
1589+
1590+func (x *ResolvedAction) Reset() {
1591+ *x = ResolvedAction{}
1592+ mi := &file_exec_proto_msgTypes[16]
1593+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1594+ ms.StoreMessageInfo(mi)
1595+}
1596+
1597+func (x *ResolvedAction) String() string {
1598+ return protoimpl.X.MessageStringOf(x)
1599+}
1600+
1601+func (*ResolvedAction) ProtoMessage() {}
1602+
1603+func (x *ResolvedAction) ProtoReflect() protoreflect.Message {
1604+ mi := &file_exec_proto_msgTypes[16]
1605+ if x != nil {
1606+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1607+ if ms.LoadMessageInfo() == nil {
1608+ ms.StoreMessageInfo(mi)
1609+ }
1610+ return ms
1611+ }
1612+ return mi.MessageOf(x)
1613+}
1614+
1615+// Deprecated: Use ResolvedAction.ProtoReflect.Descriptor instead.
1616+func (*ResolvedAction) Descriptor() ([]byte, []int) {
1617+ return file_exec_proto_rawDescGZIP(), []int{16}
1618+}
1619+
1620+func (x *ResolvedAction) GetRef() string {
1621+ if x != nil {
1622+ return x.Ref
1623+ }
1624+ return ""
1625+}
1626+
1627+func (x *ResolvedAction) GetNameWithOwner() string {
1628+ if x != nil {
1629+ return x.NameWithOwner
1630+ }
1631+ return ""
1632+}
1633+
1634+func (x *ResolvedAction) GetPath() string {
1635+ if x != nil {
1636+ return x.Path
1637+ }
1638+ return ""
1639+}
1640+
1641+func (x *ResolvedAction) GetResolvedSha() string {
1642+ if x != nil {
1643+ return x.ResolvedSha
1644+ }
1645+ return ""
1646+}
1647+
1648+func (x *ResolvedAction) GetTarballUrl() string {
1649+ if x != nil {
1650+ return x.TarballUrl
1651+ }
1652+ return ""
1653+}
1654+
1655+func (x *ResolvedAction) GetToken() string {
1656+ if x != nil {
1657+ return x.Token
1658+ }
1659+ return ""
1660+}
1661+
1662+func (x *ResolvedAction) GetSource() string {
1663+ if x != nil {
1664+ return x.Source
1665+ }
1666+ return ""
1667+}
1668+
1669+func (x *ResolvedAction) GetError() string {
1670+ if x != nil {
1671+ return x.Error
1672+ }
1673+ return ""
1674+}
1675+
1676+func (x *ResolvedAction) GetAuthOrigin() string {
1677+ if x != nil {
1678+ return x.AuthOrigin
1679+ }
1680+ return ""
1681+}
1682+
14191683 var File_exec_proto protoreflect.FileDescriptor
14201684
14211685 const file_exec_proto_rawDesc = "" +
14221686 "\n" +
14231687 "\n" +
1424- "exec.proto\x12\x15ermite.runner.exec.v1\"\xc3\x05\n" +
1688+ "exec.proto\x12\x15ermite.runner.exec.v1\"\xea\x06\n" +
14251689 "\x05Frame\x124\n" +
14261690 "\x05hello\x18\x01 \x01(\v2\x1c.ermite.runner.exec.v1.HelloH\x00R\x05hello\x12.\n" +
14271691 "\x03run\x18\x02 \x01(\v2\x1a.ermite.runner.exec.v1.RunH\x00R\x03run\x127\n" +
@@ -1435,7 +1699,9 @@ const file_exec_proto_rawDesc = "" +
14351699 "job_result\x18\t \x01(\v2 .ermite.runner.exec.v1.JobResultH\x00R\tjobResult\x12S\n" +
14361700 "\x10step_annotations\x18\n" +
14371701 " \x01(\v2&.ermite.runner.exec.v1.StepAnnotationsH\x00R\x0fstepAnnotations\x12G\n" +
1438- "\fstep_summary\x18\v \x01(\v2\".ermite.runner.exec.v1.StepSummaryH\x00R\vstepSummaryB\x06\n" +
1702+ "\fstep_summary\x18\v \x01(\v2\".ermite.runner.exec.v1.StepSummaryH\x00R\vstepSummary\x12P\n" +
1703+ "\x0fresolve_request\x18\f \x01(\v2%.ermite.runner.exec.v1.ResolveRequestH\x00R\x0eresolveRequest\x12S\n" +
1704+ "\x10resolve_response\x18\r \x01(\v2&.ermite.runner.exec.v1.ResolveResponseH\x00R\x0fresolveResponseB\x06\n" +
14391705 "\x04body\"W\n" +
14401706 "\x05Hello\x12\x18\n" +
14411707 "\aversion\x18\x01 \x01(\tR\aversion\x12\x18\n" +
@@ -1520,7 +1786,26 @@ const file_exec_proto_rawDesc = "" +
15201786 "\x0frunner_shutdown\x18\x04 \x01(\bR\x0erunnerShutdown\x1a:\n" +
15211787 "\fOutputsEntry\x12\x10\n" +
15221788 "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
1523- "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01*Y\n" +
1789+ "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"4\n" +
1790+ "\x0eResolveRequest\x12\x0e\n" +
1791+ "\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" +
1792+ "\x04uses\x18\x02 \x03(\tR\x04uses\"x\n" +
1793+ "\x0fResolveResponse\x12\x0e\n" +
1794+ "\x02id\x18\x01 \x01(\x03R\x02id\x12\x14\n" +
1795+ "\x05error\x18\x02 \x01(\tR\x05error\x12?\n" +
1796+ "\aactions\x18\x03 \x03(\v2%.ermite.runner.exec.v1.ResolvedActionR\aactions\"\x87\x02\n" +
1797+ "\x0eResolvedAction\x12\x10\n" +
1798+ "\x03ref\x18\x01 \x01(\tR\x03ref\x12&\n" +
1799+ "\x0fname_with_owner\x18\x02 \x01(\tR\rnameWithOwner\x12\x12\n" +
1800+ "\x04path\x18\x03 \x01(\tR\x04path\x12!\n" +
1801+ "\fresolved_sha\x18\x04 \x01(\tR\vresolvedSha\x12\x1f\n" +
1802+ "\vtarball_url\x18\x05 \x01(\tR\n" +
1803+ "tarballUrl\x12\x14\n" +
1804+ "\x05token\x18\x06 \x01(\tR\x05token\x12\x16\n" +
1805+ "\x06source\x18\a \x01(\tR\x06source\x12\x14\n" +
1806+ "\x05error\x18\b \x01(\tR\x05error\x12\x1f\n" +
1807+ "\vauth_origin\x18\t \x01(\tR\n" +
1808+ "authOrigin*Y\n" +
15241809 "\n" +
15251810 "StreamKind\x12\x1b\n" +
15261811 "\x17STREAM_KIND_UNSPECIFIED\x10\x00\x12\x16\n" +
@@ -1558,7 +1843,7 @@ func file_exec_proto_rawDescGZIP() []byte {
15581843 }
15591844
15601845 var file_exec_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
1561-var file_exec_proto_msgTypes = make([]protoimpl.MessageInfo, 16)
1846+var file_exec_proto_msgTypes = make([]protoimpl.MessageInfo, 19)
15621847 var file_exec_proto_goTypes = []any{
15631848 (StreamKind)(0), // 0: ermite.runner.exec.v1.StreamKind
15641849 (CancelKind)(0), // 1: ermite.runner.exec.v1.CancelKind
@@ -1578,8 +1863,11 @@ var file_exec_proto_goTypes = []any{
15781863 (*StepSummary)(nil), // 15: ermite.runner.exec.v1.StepSummary
15791864 (*Log)(nil), // 16: ermite.runner.exec.v1.Log
15801865 (*JobResult)(nil), // 17: ermite.runner.exec.v1.JobResult
1581- nil, // 18: ermite.runner.exec.v1.Run.EnvEntry
1582- nil, // 19: ermite.runner.exec.v1.JobResult.OutputsEntry
1866+ (*ResolveRequest)(nil), // 18: ermite.runner.exec.v1.ResolveRequest
1867+ (*ResolveResponse)(nil), // 19: ermite.runner.exec.v1.ResolveResponse
1868+ (*ResolvedAction)(nil), // 20: ermite.runner.exec.v1.ResolvedAction
1869+ nil, // 21: ermite.runner.exec.v1.Run.EnvEntry
1870+ nil, // 22: ermite.runner.exec.v1.JobResult.OutputsEntry
15831871 }
15841872 var file_exec_proto_depIdxs = []int32{
15851873 5, // 0: ermite.runner.exec.v1.Frame.hello:type_name -> ermite.runner.exec.v1.Hello
@@ -1593,21 +1881,24 @@ var file_exec_proto_depIdxs = []int32{
15931881 17, // 8: ermite.runner.exec.v1.Frame.job_result:type_name -> ermite.runner.exec.v1.JobResult
15941882 14, // 9: ermite.runner.exec.v1.Frame.step_annotations:type_name -> ermite.runner.exec.v1.StepAnnotations
15951883 15, // 10: ermite.runner.exec.v1.Frame.step_summary:type_name -> ermite.runner.exec.v1.StepSummary
1596- 18, // 11: ermite.runner.exec.v1.Run.env:type_name -> ermite.runner.exec.v1.Run.EnvEntry
1597- 7, // 12: ermite.runner.exec.v1.Run.job:type_name -> ermite.runner.exec.v1.JobSpec
1598- 0, // 13: ermite.runner.exec.v1.Output.stream:type_name -> ermite.runner.exec.v1.StreamKind
1599- 1, // 14: ermite.runner.exec.v1.Cancel.kind:type_name -> ermite.runner.exec.v1.CancelKind
1600- 3, // 15: ermite.runner.exec.v1.StepCompleted.conclusion:type_name -> ermite.runner.exec.v1.StepConclusion
1601- 3, // 16: ermite.runner.exec.v1.StepCompleted.outcome:type_name -> ermite.runner.exec.v1.StepConclusion
1602- 2, // 17: ermite.runner.exec.v1.Annotation.level:type_name -> ermite.runner.exec.v1.AnnotationLevel
1603- 13, // 18: ermite.runner.exec.v1.StepAnnotations.annotations:type_name -> ermite.runner.exec.v1.Annotation
1604- 3, // 19: ermite.runner.exec.v1.JobResult.conclusion:type_name -> ermite.runner.exec.v1.StepConclusion
1605- 19, // 20: ermite.runner.exec.v1.JobResult.outputs:type_name -> ermite.runner.exec.v1.JobResult.OutputsEntry
1606- 21, // [21:21] is the sub-list for method output_type
1607- 21, // [21:21] is the sub-list for method input_type
1608- 21, // [21:21] is the sub-list for extension type_name
1609- 21, // [21:21] is the sub-list for extension extendee
1610- 0, // [0:21] is the sub-list for field type_name
1884+ 18, // 11: ermite.runner.exec.v1.Frame.resolve_request:type_name -> ermite.runner.exec.v1.ResolveRequest
1885+ 19, // 12: ermite.runner.exec.v1.Frame.resolve_response:type_name -> ermite.runner.exec.v1.ResolveResponse
1886+ 21, // 13: ermite.runner.exec.v1.Run.env:type_name -> ermite.runner.exec.v1.Run.EnvEntry
1887+ 7, // 14: ermite.runner.exec.v1.Run.job:type_name -> ermite.runner.exec.v1.JobSpec
1888+ 0, // 15: ermite.runner.exec.v1.Output.stream:type_name -> ermite.runner.exec.v1.StreamKind
1889+ 1, // 16: ermite.runner.exec.v1.Cancel.kind:type_name -> ermite.runner.exec.v1.CancelKind
1890+ 3, // 17: ermite.runner.exec.v1.StepCompleted.conclusion:type_name -> ermite.runner.exec.v1.StepConclusion
1891+ 3, // 18: ermite.runner.exec.v1.StepCompleted.outcome:type_name -> ermite.runner.exec.v1.StepConclusion
1892+ 2, // 19: ermite.runner.exec.v1.Annotation.level:type_name -> ermite.runner.exec.v1.AnnotationLevel
1893+ 13, // 20: ermite.runner.exec.v1.StepAnnotations.annotations:type_name -> ermite.runner.exec.v1.Annotation
1894+ 3, // 21: ermite.runner.exec.v1.JobResult.conclusion:type_name -> ermite.runner.exec.v1.StepConclusion
1895+ 22, // 22: ermite.runner.exec.v1.JobResult.outputs:type_name -> ermite.runner.exec.v1.JobResult.OutputsEntry
1896+ 20, // 23: ermite.runner.exec.v1.ResolveResponse.actions:type_name -> ermite.runner.exec.v1.ResolvedAction
1897+ 24, // [24:24] is the sub-list for method output_type
1898+ 24, // [24:24] is the sub-list for method input_type
1899+ 24, // [24:24] is the sub-list for extension type_name
1900+ 24, // [24:24] is the sub-list for extension extendee
1901+ 0, // [0:24] is the sub-list for field type_name
16111902 }
16121903
16131904 func init() { file_exec_proto_init() }
@@ -1627,6 +1918,8 @@ func file_exec_proto_init() {
16271918 (*Frame_JobResult)(nil),
16281919 (*Frame_StepAnnotations)(nil),
16291920 (*Frame_StepSummary)(nil),
1921+ (*Frame_ResolveRequest)(nil),
1922+ (*Frame_ResolveResponse)(nil),
16301923 }
16311924 type x struct{}
16321925 out := protoimpl.TypeBuilder{
@@ -1634,7 +1927,7 @@ func file_exec_proto_init() {
16341927 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
16351928 RawDescriptor: unsafe.Slice(unsafe.StringData(file_exec_proto_rawDesc), len(file_exec_proto_rawDesc)),
16361929 NumEnums: 4,
1637- NumMessages: 16,
1930+ NumMessages: 19,
16381931 NumExtensions: 0,
16391932 NumServices: 0,
16401933 },
M internal/execstream/frame.go +83 -0
@@ -13,11 +13,13 @@ package execstream
1313
1414 import (
1515 "bufio"
16+ "context"
1617 "encoding/binary"
1718 "errors"
1819 "fmt"
1920 "io"
2021 "sync"
22+ "time"
2123
2224 "google.golang.org/protobuf/proto"
2325
@@ -105,6 +107,45 @@ func Drive(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, onOu
105107 return DriveJob(rw, run, cancel, JobHandler{Output: onOutput})
106108 }
107109
110+// resolveJoinGrace is how long DriveJob waits for the resolution worker to
111+// finish once the session is over before it interrupts a write that is stuck
112+// (a guest that stopped reading): the write deadline is set to now, and the
113+// stream is closed if it cannot be interrupted otherwise. The session is over at
114+// that point, the caller closes the stream anyway.
115+var resolveJoinGrace = 2 * time.Second
116+
117+// joinResolver waits for the worker. A worker in a Send that cannot finish
118+// because the peer does not read would block DriveJob for ever, so the write is
119+// interrupted after the grace period.
120+func joinResolver(rw io.ReadWriter, workers *sync.WaitGroup) {
121+ finished := make(chan struct{})
122+ go func() { workers.Wait(); close(finished) }()
123+ select {
124+ case <-finished:
125+ return
126+ case <-time.After(resolveJoinGrace):
127+ }
128+ if d, ok := rw.(interface{ SetWriteDeadline(time.Time) error }); ok {
129+ _ = d.SetWriteDeadline(time.Now())
130+ }
131+ select {
132+ case <-finished:
133+ return
134+ case <-time.After(resolveJoinGrace):
135+ }
136+ if cl, ok := rw.(io.Closer); ok {
137+ _ = cl.Close()
138+ <-finished
139+ }
140+}
141+
142+// resolveQueue is how many ResolveRequest frames may wait for the worker.
143+const resolveQueue = 2
144+
145+func resolveFrame(r *execpb.ResolveResponse) *execpb.Frame {
146+ return &execpb.Frame{Body: &execpb.Frame_ResolveResponse{ResolveResponse: r}}
147+}
148+
108149 // JobHandler receives the frames of a session. Nil functions ignore their
109150 // frames. The functions are called from the goroutine of DriveJob, in the
110151 // order of the stream.
@@ -115,6 +156,13 @@ type JobHandler struct {
115156 StepCompleted func(*execpb.StepCompleted)
116157 Log func(*execpb.Log)
117158 JobResult func(*execpb.JobResult)
159+ // Resolve answers the executor's ResolveRequest frames, one at a time and in
160+ // order, from a single worker (the call goes to the server); the answer is
161+ // sent as a ResolveResponse frame carrying the id of the request. ctx ends
162+ // with the session: a call still running is cancelled and DriveJob waits for
163+ // it before it returns. At most resolveQueue requests wait; the next ones are
164+ // answered with an error at once, without a call.
165+ Resolve func(ctx context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse
118166 // StepAnnotations and StepSummary come before the StepCompleted of their
119167 // step.
120168 StepAnnotations func(*execpb.StepAnnotations)
@@ -142,6 +190,30 @@ func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h
142190
143191 done := make(chan struct{})
144192 defer close(done)
193+
194+ // One worker answers the resolutions; it ends, and is waited for, with the
195+ // session.
196+ resolveCh := make(chan *execpb.ResolveRequest, resolveQueue)
197+ rctx, rcancel := context.WithCancel(context.Background())
198+ var workers sync.WaitGroup
199+ workers.Add(1)
200+ go func() {
201+ defer workers.Done()
202+ for {
203+ select {
204+ case req := <-resolveCh:
205+ resp := h.Resolve(rctx, req)
206+ resp.Id = req.Id
207+ if rctx.Err() != nil {
208+ return // the session is over: nobody reads the answer
209+ }
210+ _ = c.Send(resolveFrame(resp))
211+ case <-rctx.Done():
212+ return
213+ }
214+ }
215+ }()
216+ defer func() { rcancel(); joinResolver(rw, &workers) }()
145217 if cancel != nil {
146218 go func() {
147219 select {
@@ -183,6 +255,17 @@ func DriveJob(rw io.ReadWriter, run *execpb.Run, cancel <-chan *execpb.Cancel, h
183255 if h.JobResult != nil {
184256 h.JobResult(b.JobResult)
185257 }
258+ case *execpb.Frame_ResolveRequest:
259+ req := b.ResolveRequest
260+ if h.Resolve == nil {
261+ _ = c.Send(resolveFrame(&execpb.ResolveResponse{Id: req.Id, Error: "the host cannot resolve actions"}))
262+ break
263+ }
264+ select {
265+ case resolveCh <- req:
266+ default:
267+ _ = c.Send(resolveFrame(&execpb.ResolveResponse{Id: req.Id, Error: "too many action resolutions in flight"}))
268+ }
186269 case *execpb.Frame_StepAnnotations:
187270 if h.StepAnnotations != nil {
188271 h.StepAnnotations(b.StepAnnotations)
M internal/execstream/frame_test.go +156 -0
@@ -4,11 +4,13 @@ package execstream
44
55 import (
66 "bytes"
7+ "context"
78 "encoding/binary"
89 "errors"
910 "io"
1011 "net"
1112 "strings"
13+ "sync/atomic"
1214 "testing"
1315 "testing/iotest"
1416 "time"
@@ -172,3 +174,157 @@ func TestDriveJobDeliversJobFrames(t *testing.T) {
172174 t.Fatalf("%v", got)
173175 }
174176 }
177+
178+// GO-003: a flood of ResolveRequest frames is answered by one worker, the ones
179+// beyond the queue with an error and no call, and DriveJob does not return
180+// before the worker is done (a call in flight is cancelled).
181+func TestDriveJobResolveBurst(t *testing.T) {
182+ ln, err := net.Listen("tcp", "127.0.0.1:0")
183+ if err != nil {
184+ t.Skip(err)
185+ }
186+ defer func() { _ = ln.Close() }()
187+ g, err := net.Dial("tcp", ln.Addr().String())
188+ if err != nil {
189+ t.Fatal(err)
190+ }
191+ h, err := ln.Accept()
192+ if err != nil {
193+ t.Fatal(err)
194+ }
195+ defer func() { _ = g.Close() }()
196+ gc := NewConn(g)
197+ if err := gc.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{}}}); err != nil {
198+ t.Fatal(err)
199+ }
200+ var running, maxRunning, started atomic.Int32
201+ release := make(chan struct{})
202+ var exited atomic.Bool
203+ done := make(chan struct{})
204+ go func() {
205+ defer close(done)
206+ _, _, _ = DriveJob(h, &execpb.Run{}, nil, JobHandler{Resolve: func(ctx context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse {
207+ n := running.Add(1)
208+ defer running.Add(-1)
209+ for {
210+ m := maxRunning.Load()
211+ if n <= m || maxRunning.CompareAndSwap(m, n) {
212+ break
213+ }
214+ }
215+ started.Add(1)
216+ select {
217+ case <-release:
218+ case <-ctx.Done():
219+ if exited.Load() {
220+ t.Error("the worker outlived DriveJob")
221+ }
222+ }
223+ return &execpb.ResolveResponse{}
224+ }})
225+ exited.Store(true)
226+ }()
227+ // 50 requests at once while the first call is held.
228+ for i := int64(1); i <= 50; i++ {
229+ if err := gc.Send(&execpb.Frame{Body: &execpb.Frame_ResolveRequest{ResolveRequest: &execpb.ResolveRequest{Id: i, Uses: []string{"a/b@v1"}}}}); err != nil {
230+ t.Fatal(err)
231+ }
232+ }
233+ // Collect the refusals, which are immediate: until the stream is quiet. The
234+ // worker may or may not have taken the first request before the queue filled,
235+ // so the queue and the running call hold 2 or 3 of the 50.
236+ refused := 0
237+ for {
238+ _ = g.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
239+ f, err := gc.Recv()
240+ if err != nil {
241+ break
242+ }
243+ if r := f.GetResolveResponse(); r != nil && r.Error != "" {
244+ refused++
245+ }
246+ }
247+ _ = g.SetReadDeadline(time.Time{})
248+ if refused < 50-1-resolveQueue || refused > 50-resolveQueue {
249+ t.Fatalf("%d refused of 50, want %d or %d", refused, 50-1-resolveQueue, 50-resolveQueue)
250+ }
251+ if maxRunning.Load() != 1 || started.Load() != 1 {
252+ t.Errorf("running %d, started %d: resolutions are serial", maxRunning.Load(), started.Load())
253+ }
254+ // End the session with a call still in flight: it is cancelled and joined.
255+ _ = g.Close()
256+ select {
257+ case <-done:
258+ case <-time.After(5 * time.Second):
259+ t.Fatal("DriveJob did not return")
260+ }
261+ close(release)
262+}
263+
264+// GO-009: the resolution worker is in a Send that cannot finish because the
265+// guest stopped reading, and the guest ends the session with Exit. DriveJob
266+// must return, not wait for the write for ever.
267+func TestDriveJobReturnsWhenTheResolverWriteIsStuck(t *testing.T) {
268+ old := resolveJoinGrace
269+ resolveJoinGrace = 50 * time.Millisecond
270+ defer func() { resolveJoinGrace = old }()
271+ hostSide, guestSide := net.Pipe() // synchronous: a write needs a reader
272+ defer func() { _ = guestSide.Close() }()
273+ gc := NewConn(guestSide)
274+ go func() {
275+ _ = gc.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{}}})
276+ if _, err := gc.Recv(); err != nil { // the Run frame
277+ return
278+ }
279+ _ = gc.Send(&execpb.Frame{Body: &execpb.Frame_ResolveRequest{ResolveRequest: &execpb.ResolveRequest{Id: 1, Uses: []string{"a/b@v1"}}}})
280+ time.Sleep(100 * time.Millisecond) // the answer is being written: nobody reads it
281+ _ = gc.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{}}})
282+ }()
283+ done := make(chan error, 1)
284+ go func() {
285+ _, exit, err := DriveJob(hostSide, &execpb.Run{}, nil, JobHandler{Resolve: func(context.Context, *execpb.ResolveRequest) *execpb.ResolveResponse {
286+ return &execpb.ResolveResponse{}
287+ }})
288+ if err == nil && exit == nil {
289+ err = errors.New("no exit")
290+ }
291+ done <- err
292+ }()
293+ select {
294+ case err := <-done:
295+ if err != nil {
296+ t.Fatal(err)
297+ }
298+ case <-time.After(10 * time.Second):
299+ t.Fatal("DriveJob is blocked by a write nobody reads")
300+ }
301+}
302+
303+// And a worker that has an answer when the session ends does not write it.
304+func TestDriveJobDoesNotSendAfterTheSession(t *testing.T) {
305+ hostSide, guestSide := net.Pipe()
306+ defer func() { _ = guestSide.Close() }()
307+ gc := NewConn(guestSide)
308+ release := make(chan struct{})
309+ go func() {
310+ _ = gc.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{}}})
311+ _, _ = gc.Recv()
312+ _ = gc.Send(&execpb.Frame{Body: &execpb.Frame_ResolveRequest{ResolveRequest: &execpb.ResolveRequest{Id: 1, Uses: []string{"a/b@v1"}}}})
313+ _ = gc.Send(&execpb.Frame{Body: &execpb.Frame_Exit{Exit: &execpb.Exit{}}})
314+ close(release)
315+ }()
316+ done := make(chan struct{})
317+ go func() {
318+ defer close(done)
319+ _, _, _ = DriveJob(hostSide, &execpb.Run{}, nil, JobHandler{Resolve: func(ctx context.Context, _ *execpb.ResolveRequest) *execpb.ResolveResponse {
320+ <-ctx.Done() // returns only when the session ends
321+ return &execpb.ResolveResponse{}
322+ }})
323+ }()
324+ select {
325+ case <-done:
326+ case <-time.After(10 * time.Second):
327+ t.Fatal("DriveJob did not return")
328+ }
329+ <-release
330+}
A internal/executor/actions_cancel_test.go +190 -0
@@ -0,0 +1,190 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "net/http"
8+ "net/http/httptest"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "sync"
13+ "testing"
14+ "time"
15+)
16+
17+// GO-006: the image setup of a local composite action (pull, retry sleep) stops
18+// with the step.
19+func TestLocalCompositeSetupIsCancellable(t *testing.T) {
20+ needTools(t, "bash")
21+ dir := t.TempDir()
22+ pulls := filepath.Join(dir, "pulls")
23+ script := "#!/bin/sh\necho x >> " + pulls + "\necho ready\nexit 1\n"
24+ if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil {
25+ t.Fatal(err)
26+ }
27+ c := NewCanceller()
28+ started := make(chan struct{})
29+ go func() {
30+ <-started
31+ c.Cancel(CancelUser)
32+ }()
33+ home := t.TempDir()
34+ layout := NewLayout(home, "hello", home+"/tc")
35+ writeFiles(t, layout.Workspace, files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: docker://alpine:3\n"})
36+ rec := newRecorder()
37+ rep := &signalReporter{recorder: rec, started: started, match: "ready"}
38+ done := make(chan Result, 1)
39+ go func() {
40+ done <- Run(c, Config{
41+ WorkflowYAML: []byte(wfHead + " steps:\n - uses: ./c\n - if: always()\n run: echo cleanup-ran\n"),
42+ JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rep,
43+ BaseEnv: []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home},
44+ TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, WaitDelay: 500 * time.Millisecond,
45+ Actions: ActionsConfig{DockerPath: filepath.Join(dir, "docker"), Backoff: func() time.Duration { return time.Hour }},
46+ })
47+ }()
48+ select {
49+ case res := <-done:
50+ if res.Conclusion != ConclusionCancelled {
51+ t.Fatalf("%+v", res)
52+ }
53+ case <-time.After(20 * time.Second):
54+ t.Fatal("the retry sleep of the pull ignored the cancellation")
55+ }
56+ if b, _ := os.ReadFile(pulls); strings.Count(string(b), "x") != 1 {
57+ t.Errorf("no retry after the cancellation: %q", b)
58+ }
59+ if got := rec.step(t, "Run ./c").Conclusion; got != ConclusionCancelled {
60+ t.Errorf("step %s", got)
61+ }
62+ if got := rec.step(t, "Run echo cleanup-ran").Conclusion; got != ConclusionSuccess {
63+ t.Errorf("always() step %s", got)
64+ }
65+}
66+
67+// GO-014: what the timeout or the cancellation of a step breaks while a local
68+// composite action is prepared (a resolution, a download, an image pull and its
69+// retry sleep) ends the step as the cancellation of the step does: the timeout
70+// message and a failed step for timeout-minutes, a cancelled step for a user
71+// cancel, and no stray "The operation was canceled.".
72+func TestPreparationInterruptedByTheStepTimeout(t *testing.T) {
73+ skew := -(60*time.Second - 1500*time.Millisecond)
74+ leaf := files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: o/leaf@v1\n"}
75+ docker := files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: docker://alpine:3\n"}
76+ hang := make(chan struct{})
77+ slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
78+ select {
79+ case <-r.Context().Done():
80+ case <-hang:
81+ }
82+ }))
83+ defer slow.Close()
84+ defer close(hang)
85+ failDocker := filepath.Join(t.TempDir(), "docker")
86+ writeFiles(t, filepath.Dir(failDocker), files{"docker": "#!/bin/sh\necho pull failed\nexit 1\n"})
87+
88+ for name, tc := range map[string]struct {
89+ tree files
90+ tweak func(*Config)
91+ }{
92+ "blocked resolver": {leaf, func(c *Config) {
93+ c.Actions.Resolver = func(ctx context.Context, _ []string) ([]ResolvedAction, error) {
94+ <-ctx.Done()
95+ return nil, ctx.Err()
96+ }
97+ }},
98+ "blocked download": {leaf, func(c *Config) {
99+ c.Actions.Resolver = func(context.Context, []string) ([]ResolvedAction, error) {
100+ return []ResolvedAction{{Ref: "o/leaf@v1", NameWithOwner: "o/leaf", ResolvedSHA: strings.Repeat("a", 40), TarballURL: slow.URL + "/t"}}, nil
101+ }
102+ }},
103+ "docker pull retry sleep": {docker, func(c *Config) {
104+ c.Actions.DockerPath = failDocker
105+ c.Actions.Backoff = func() time.Duration { return time.Hour }
106+ }},
107+ } {
108+ t.Run(name, func(t *testing.T) {
109+ start := time.Now()
110+ rr, res := actionResults(t, tc.tree, " - name: comp\n timeout-minutes: 1\n uses: ./c\n - name: after\n if: always()\n run: echo after-ran\n", func(c *Config) {
111+ c.StepTimeoutSkew = skew
112+ tc.tweak(c)
113+ })
114+ if time.Since(start) > 15*time.Second {
115+ t.Fatalf("the preparation was not interrupted: %v", time.Since(start))
116+ }
117+ comp := rr.step(t, "comp")
118+ log := logOf(comp)
119+ if res.Conclusion != ConclusionFailure || comp.Conclusion != ConclusionFailure {
120+ t.Fatalf("job %s, step %s\n%s", res.Conclusion, comp.Conclusion, log)
121+ }
122+ if !strings.Contains(log, "##[error]The action 'comp' has timed out after 1 minutes.") {
123+ t.Errorf("no timeout message:\n%s", log)
124+ }
125+ if strings.Contains(log, "The operation was canceled.") {
126+ t.Errorf("a stray cancel text:\n%s", log)
127+ }
128+ if rr.step(t, "after").Conclusion != ConclusionSuccess {
129+ t.Error("the job is not cancelled by a step timeout")
130+ }
131+ })
132+ }
133+}
134+
135+func TestPreparationInterruptedByAUserCancel(t *testing.T) {
136+ needTools(t, "bash")
137+ entered := make(chan struct{})
138+ var once sync.Once
139+ home := t.TempDir()
140+ layout := NewLayout(home, "hello", home+"/tc")
141+ writeFiles(t, layout.Workspace, files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: o/leaf@v1\n"})
142+ c := NewCanceller()
143+ go func() {
144+ <-entered
145+ c.Cancel(CancelUser)
146+ }()
147+ rec := newRecorder()
148+ done := make(chan Result, 1)
149+ go func() {
150+ done <- Run(c, Config{
151+ WorkflowYAML: []byte(wfHead + " steps:\n - name: comp\n uses: ./c\n - name: cleanup\n if: always()\n run: echo cleanup-ran\n"),
152+ JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rec,
153+ BaseEnv: []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home},
154+ TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, WaitDelay: 500 * time.Millisecond,
155+ Actions: ActionsConfig{Resolver: func(ctx context.Context, _ []string) ([]ResolvedAction, error) {
156+ once.Do(func() { close(entered) })
157+ <-ctx.Done()
158+ return nil, ctx.Err()
159+ }},
160+ })
161+ }()
162+ select {
163+ case res := <-done:
164+ if res.Conclusion != ConclusionCancelled {
165+ t.Fatalf("%+v", res)
166+ }
167+ case <-time.After(20 * time.Second):
168+ t.Fatal("the resolution ignored the cancellation")
169+ }
170+ if got := rec.step(t, "comp").Conclusion; got != ConclusionCancelled {
171+ t.Errorf("step %s, want cancelled", got)
172+ }
173+ if got := rec.step(t, "cleanup").Conclusion; got != ConclusionSuccess {
174+ t.Errorf("always() step %s", got)
175+ }
176+}
177+
178+// A genuine error of the preparation still fails the step.
179+func TestPreparationErrorStillFailsTheStep(t *testing.T) {
180+ tree := files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: o/leaf@v1\n"}
181+ rr, res := actionResults(t, tree, " - name: comp\n uses: ./c\n", func(c *Config) {
182+ c.Actions.Resolver = func(context.Context, []string) ([]ResolvedAction, error) {
183+ return []ResolvedAction{{Ref: "o/leaf@v1", Error: "Unable to resolve action `o/leaf@v1`, repository not found: `o/leaf`"}}, nil
184+ }
185+ })
186+ comp := rr.step(t, "comp")
187+ if res.Conclusion != ConclusionFailure || comp.Conclusion != ConclusionFailure || !strings.Contains(logOf(comp), "##[error]Unable to resolve action `o/leaf@v1`, repository not found: `o/leaf`") {
188+ t.Errorf("%s %s\n%s", res.Conclusion, comp.Conclusion, logOf(comp))
189+ }
190+}
A internal/executor/actions_composite.go +229 -0
@@ -0,0 +1,229 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+
8+ "bnema.dev/ermite-actions/expr"
9+)
10+
11+// compositeGitHubKeys are the keys of the github context that a composite
12+// action's steps change: the runner gives them a copy of the context
13+// (CompositeActionHandler.cs:152-161), the executor restores these afterwards.
14+func compositeGitHubKeys() []string {
15+ keys := []string{"action_path", "action_status", "action_repository", "action_ref", "event_path"}
16+ for _, c := range fileCommands {
17+ keys = append(keys, c.ContextName)
18+ }
19+ return keys
20+}
21+
22+// maxLocalDepth is the nesting of composite actions the executor allows. It is
23+// ermite's guard against a local composite action that uses itself, not a limit
24+// of GitHub's, whose runner has none for local actions.
25+const maxLocalDepth = 100
26+
27+// prepareLocalComposite is the first part of ActionRunner.RunAsync for a local
28+// composite action (ActionRunner.cs:77-96): its steps are prepared when the
29+// step runs, in the log of the step, and the pulls and builds they need run
30+// first as embedded steps.
31+func (j *job) prepareLocalComposite(s *stepRun, def *actionDef, cancel <-chan struct{}) error {
32+ n := s.n
33+ if n.children != nil {
34+ return nil // pre and post stages reuse the nodes of the main stage
35+ }
36+ // Local composite actions have no depth limit on GitHub: the recording of
37+ // ermite-conformance corpus/act-composite-depth has composites of 9, 10 and 12
38+ // levels, all of which succeed. The runner prepares the steps of a local
39+ // composite when its step runs, with the depth restarting at 1
40+ // (ActionRunner.cs:77-96), which is why. This port does the same, and the
41+ // resolutions it needs are cached and budgeted by the host (host/resolve.go).
42+ //
43+ // The one guard that is ermite's own, not GitHub's: a local composite that
44+ // uses itself (directly or in a cycle) would recurse until the executor
45+ // crashes, so the nesting of composite actions is capped at maxLocalDepth.
46+ if d := s.depth(); d >= maxLocalDepth {
47+ return ghErrorf("Composite action depth exceeded max depth %d", maxLocalDepth)
48+ }
49+ kids, err := newNodes(def.act.Runs.Steps, n, n.scope)
50+ if err != nil {
51+ return err
52+ }
53+ n.children = kids
54+ n.preSteps, n.postSteps = nil, nil
55+ j.out(s.num, "Prepare all required actions")
56+ if j.actions == nil {
57+ j.actions = newActionState()
58+ }
59+ saved := *j.actions
60+ j.actions.pullOrder, j.actions.buildOrder = nil, nil
61+ j.actions.pullUsers, j.actions.buildUsers = map[string][]*node{}, map[string][]*node{}
62+ // The preparation stops with the step, and only with it: its context ends when
63+ // the cancel channel of the step closes (job cancellation for a step that does
64+ // not run on always(), timeout-minutes). A step that keeps running after the
65+ // job was cancelled keeps the means to prepare its composite.
66+ ctx, stop := context.WithCancel(context.Background())
67+ defer stop()
68+ if cancel != nil {
69+ go func() {
70+ select {
71+ case <-cancel:
72+ stop()
73+ case <-ctx.Done():
74+ }
75+ }()
76+ }
77+ if err := j.prepare(ctx, s.num, kids, 1, n); err != nil {
78+ return err
79+ }
80+ setups := j.setupSteps()
81+ j.actions.pullOrder, j.actions.buildOrder = saved.pullOrder, saved.buildOrder
82+ j.actions.pullUsers, j.actions.buildUsers = saved.pullUsers, saved.buildUsers
83+ for _, q := range setups {
84+ if r := q.setup(s, cancel); r != resSucceeded {
85+ if r == resCanceled {
86+ return errSetupCancelled
87+ }
88+ return errSetupFailed
89+ }
90+ }
91+ return nil
92+}
93+
94+// errSetupFailed ends a composite whose image setup failed; the cause is in the
95+// log already.
96+// errSetupCancelled: the step was cancelled while its images were prepared; the
97+// cancellation is logged by the docker command already.
98+var errSetupCancelled = ghErrorf("The operation was canceled.")
99+
100+var errSetupFailed = ghErrorf("A container setup step of the composite action failed.")
101+
102+// runComposite is CompositeActionHandler.RunAsync (CompositeActionHandler.cs:
103+// 30-207) and RunStepsAsync: the steps of the stage run in their own steps
104+// context with the inputs of the action, and the outputs are mapped from them.
105+func (j *job) runComposite(s *stepRun, def *actionDef, in *inputSet, env *ordered) stepResult {
106+ n := s.n
107+ var kids []*node
108+ switch s.stage {
109+ case stagePre:
110+ kids = n.preSteps
111+ case stagePost:
112+ // Only the steps that registered a post, last registered first.
113+ for i := len(n.postSteps) - 1; i >= 0; i-- {
114+ if c := n.postSteps[i]; c.postRegistered {
115+ kids = append(kids, c)
116+ }
117+ }
118+ default:
119+ kids = n.children
120+ }
121+ inputs := expr.NewDictionary()
122+ for _, k := range in.keys {
123+ v, _ := in.Get(k)
124+ inputs.Add(k, expr.String(v))
125+ }
126+ // The pre stage has no context name in the runner, so its steps context is a
127+ // random one; post shares the scope of main (siblingScopeName).
128+ scope := n.scope
129+ if s.stage == stagePre {
130+ scope = "__pre_" + n.scope
131+ }
132+ j.mu.Lock()
133+ restoreGH := j.state.GitHubKeys(compositeGitHubKeys()...)
134+ restoreScope := j.state.PushScope(scope, inputs)
135+ j.state.SetGitHub("action_path", def.dir)
136+ j.mu.Unlock()
137+ restored := false
138+ restore := func() {
139+ if !restored {
140+ restored = true
141+ restoreScope()
142+ restoreGH()
143+ }
144+ }
145+ defer func() {
146+ j.mu.Lock()
147+ restore()
148+ j.mu.Unlock()
149+ }()
150+ ok := resSucceeded
151+ s.result = &ok
152+ for _, c := range kids {
153+ j.runEmbedded(s, c)
154+ }
155+ // Outputs are evaluated in the steps context of the action and belong to the
156+ // step that uses it (ExecutionContext.SetOutput of the composite step).
157+ var outs []Pair
158+ if s.stage == stageMain {
159+ outs = j.compositeOutputs(s, def)
160+ }
161+ j.mu.Lock()
162+ restore()
163+ for _, o := range outs {
164+ j.state.SetOutput(s.st.ID, o.Key, o.Value)
165+ }
166+ j.mu.Unlock()
167+ return *s.result
168+}
169+
170+// compositeOutputs is ProcessOutputs (CompositeActionHandler.cs:192-228): the
171+// "value" of each output of the action, evaluated with the steps and inputs
172+// of the action.
173+func (j *job) compositeOutputs(s *stepRun, def *actionDef) []Pair {
174+ var out []Pair
175+ j.mu.Lock()
176+ defer j.mu.Unlock()
177+ for _, o := range def.act.Outputs {
178+ if o.Value == nil {
179+ continue
180+ }
181+ v, err := j.evalString(o.Value, s.env)
182+ if err != nil {
183+ j.mu.Unlock()
184+ j.errorf(s.num, "%s", templateNotValid(err, true))
185+ j.mu.Lock()
186+ m := resFailed
187+ s.result = &m
188+ return nil
189+ }
190+ out = append(out, Pair{o.Name, v})
191+ }
192+ return out
193+}
194+
195+// runEmbedded is one iteration of CompositeActionHandler.RunStepsAsync: the
196+// step of the composite action runs inside the step that uses it, with its own
197+// environment, condition, cancellation watch and result. Its log lines belong
198+// to the outer step.
199+func (j *job) runEmbedded(parent *stepRun, c *node) {
200+ s := &stepRun{j: j, num: parent.num, st: c.st, n: c, stage: parent.stage, parent: parent, fc: j.fc.Fork()}
201+ // The issues, summaries and state of an inner step belong to the step that
202+ // uses the action, whose collector counts them (10 per level, the outer
203+ // step's own first).
204+ s.scope = j.embeddedScope(parent, c.stateKey)
205+ defer s.fc.Cleanup()
206+ s.cmds = newCommandManager(s, j.cfg.Debug)
207+ j.mu.Lock()
208+ // github.action_status: the result of the composite action so far.
209+ j.state.SetGitHub("action_status", parent.actionStatus())
210+ j.mu.Unlock()
211+ env, envErr := j.stepEnvironment(s)
212+ s.env = env
213+ s.name = j.displayName(c.st, env)
214+ if envErr != nil {
215+ j.errorf(s.num, "%s", templateNotValid(envErr, true))
216+ j.finishStep(s, resFailed)
217+ return
218+ }
219+ q := qstep{n: c, stage: parent.stage}
220+ switch parent.stage {
221+ case stagePre:
222+ if c.def != nil {
223+ q.cond = c.def.preIf()
224+ }
225+ case stagePost:
226+ q.cond = c.postCond
227+ }
228+ j.runGuarded(s, q, true)
229+}
A internal/executor/actions_docker.go +309 -0
@@ -0,0 +1,309 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+
12+ "bnema.dev/ermite-actions/action"
13+ "bnema.dev/ermite-actions/expr"
14+ "bnema.dev/ermite-actions/template"
15+)
16+
17+// pathMap is ContainerInfo's path translation for a docker action: the host
18+// directories mounted below /github. The runner inserts each mapping first, so
19+// the last added is tried first (ContainerInfo.AddPathTranslateMapping).
20+type pathMap struct{ host, container string }
21+
22+func translatePath(maps []pathMap, p string) string {
23+ if p == "" {
24+ return p
25+ }
26+ for _, m := range maps {
27+ if p == m.host {
28+ return m.container
29+ }
30+ if strings.HasPrefix(p, m.host+"/") {
31+ return m.container + p[len(m.host):]
32+ }
33+ }
34+ return p
35+}
36+
37+// ctx is the context of the job's own operations (downloads, resolutions): it
38+// ends when the job is cancelled.
39+func (j *job) ctx() context.Context {
40+ if j.jobCtx == nil {
41+ return context.Background()
42+ }
43+ return j.jobCtx
44+}
45+
46+// dockerEnvContext evaluates against the contexts of the step with the inputs
47+// context replaced: the arguments and the env of a container action see its
48+// inputs, with the defaults (ContainerActionHandler.cs:122-131).
49+func (j *job) dockerEnvContext(env *ordered, in *inputSet) template.Env {
50+ te := j.evalEnv(env)
51+ ctxs := make(map[string]expr.Value, len(te.Contexts)+1)
52+ for k, v := range te.Contexts {
53+ ctxs[k] = v
54+ }
55+ d := expr.NewDictionary()
56+ for _, k := range in.keys {
57+ v, _ := in.Get(k)
58+ d.Add(k, expr.String(v))
59+ }
60+ ctxs["inputs"] = d
61+ te.Contexts = ctxs
62+ return te
63+}
64+
65+// quoteArg is the quoting of one argument of the entrypoint
66+// (ContainerActionHandler.cs:139-148): in double quotes, a quote preceded by a
67+// backslash; an empty argument is "".
68+func quoteArg(a string) string {
69+ if a == "" {
70+ return `""`
71+ }
72+ return `"` + strings.ReplaceAll(a, `"`, `\"`) + `"`
73+}
74+
75+// dockerBuildCmd is docker build (DockerCommandManager.DockerBuild).
76+func (j *job) dockerBuildCmd(tag, dockerfile, context string) dockerCmd {
77+ return j.newDockerCmd("build",
78+ dockerPart{line: "-t " + tag, args: []string{"-t", tag}},
79+ dockerPart{line: `-f "` + dockerfile + `"`, args: []string{"-f", dockerfile}},
80+ dockerPart{line: `"` + context + `"`, args: []string{context}})
81+}
82+
83+// buildImage builds an image from a Dockerfile, in the step.
84+func (j *job) buildImage(s *stepRun, tag, dockerfile, workDir string, cancel <-chan struct{}) error {
85+ c := j.dockerBuildCmd(tag, dockerfile, filepath.Dir(dockerfile))
86+ code, err := j.runDockerCmd(s, c, dirOrCwd(workDir), nil, cancel)
87+ if err != nil {
88+ return err
89+ }
90+ if code != 0 {
91+ return ghErrorf("Docker build failed with exit code %d", code)
92+ }
93+ return nil
94+}
95+
96+// runDocker is ContainerActionHandler.RunAsync (ContainerActionHandler.cs:
97+// 34-277) in a sandbox that has the docker CLI: the same mounts, variables and
98+// options as the runner's docker run.
99+func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered, cancel <-chan struct{}) stepResult {
100+ num, n := s.num, s.n
101+ fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
102+ for _, k := range in.keys {
103+ v, _ := in.Get(k)
104+ env.Set(inputEnvName(k), v)
105+ }
106+ j.mu.Lock()
107+ workspace := j.state.GitHub("workspace")
108+ temp := j.state.Layout().Temp
109+ j.mu.Unlock()
110+ image := n.image
111+ if image == "" {
112+ var build string
113+ if def.act == nil && def.dockerfile != "" {
114+ build = def.dockerfile
115+ } else if def.act == nil {
116+ image = def.image
117+ } else {
118+ im, err := action.ParseImage(def.image)
119+ if err != nil {
120+ return fail(err)
121+ }
122+ if im.Kind == action.ImageRegistry {
123+ image = im.Ref
124+ } else {
125+ build = filepath.Join(def.dir, filepath.FromSlash(im.Path))
126+ }
127+ }
128+ if build != "" {
129+ if _, err := os.Stat(build); err != nil {
130+ return fail(ghErrorf("File not found: '%s'", build))
131+ }
132+ j.out(num, "##[group]Building docker image")
133+ j.out(num, fmt.Sprintf("Dockerfile for action: '%s'.", build))
134+ image = newImageTag(j.dockerLabel())
135+ err := j.buildImage(s, image, build, workspace, cancel)
136+ j.out(num, "##[endgroup]")
137+ if err != nil {
138+ if err == errCancelled {
139+ return j.cancelledStep(s)
140+ }
141+ return fail(err)
142+ }
143+ }
144+ }
145+ var entry string
146+ r := def.runs()
147+ switch s.stage {
148+ case stageMain:
149+ if r != nil && r.Entrypoint != "" {
150+ entry = r.Entrypoint
151+ } else if v, ok := in.Get("entryPoint"); ok {
152+ entry = v
153+ }
154+ case stagePre:
155+ if r != nil {
156+ entry = r.PreEntrypoint
157+ }
158+ case stagePost:
159+ if r != nil {
160+ entry = r.PostEntrypoint
161+ }
162+ }
163+ te := j.dockerEnvContext(env, in)
164+ argLine, argList, err := j.dockerArgs(r, in, te)
165+ if err != nil {
166+ j.errorf(num, "%s", templateNotValid(err, true))
167+ return resFailed
168+ }
169+ if r != nil && r.Env != nil {
170+ v, err := template.Evaluate(context.Background(), r.Env, te)
171+ if err != nil {
172+ j.errorf(num, "%s", templateNotValid(err, true))
173+ return resFailed
174+ }
175+ if d, ok := v.(*expr.Dictionary); ok {
176+ for _, p := range d.Pairs() {
177+ if _, has := env.Get(p.Key); !has {
178+ env.Set(p.Key, expr.CoerceString(p.Value))
179+ }
180+ }
181+ }
182+ }
183+ // The directories the container sees (ContainerActionHandler.cs:171-198).
184+ home := filepath.Join(temp, "_github_home")
185+ workflowDir := filepath.Join(temp, "_github_workflow")
186+ fcDir := filepath.Join(temp, "_runner_file_commands")
187+ for _, d := range []string{home, workflowDir} {
188+ if err := os.MkdirAll(d, 0o755); err != nil {
189+ return fail(err)
190+ }
191+ j.own(d)
192+ }
193+ env.Set("HOME", home)
194+ maps := []pathMap{ // last added first
195+ {workspace, "/github/workspace"}, {fcDir, "/github/file_commands"}, {workflowDir, "/github/workflow"},
196+ {home, "/github/home"}, {temp, "/github/runner_temp"},
197+ }
198+ // The runtime variables of the contexts (GITHUB_*, RUNNER_*).
199+ j.mu.Lock()
200+ runtime := j.state.RuntimeEnv()
201+ j.mu.Unlock()
202+ for _, k := range runtime.Keys() {
203+ v, _ := runtime.Get(k)
204+ env.Set(k, v)
205+ }
206+ cenv := newOrdered()
207+ for _, k := range env.Keys() {
208+ v, _ := env.Get(k)
209+ cenv.Set(k, translatePath(maps, v))
210+ }
211+ parts := []dockerPart{
212+ raw("--name", sanitizeName(image)+"_"+shortID()),
213+ raw("--label", j.dockerLabel()),
214+ raw("--workdir", "/github/workspace"),
215+ raw("--rm"),
216+ }
217+ for _, k := range cenv.Keys() {
218+ parts = append(parts, envOpt(k))
219+ }
220+ parts = append(parts, raw("-e", "GITHUB_ACTIONS=true"))
221+ if _, ok := cenv.Get("CI"); !ok {
222+ parts = append(parts, raw("-e", "CI=true"))
223+ }
224+ if entry != "" {
225+ parts = append(parts, entrypointOpt(entry))
226+ }
227+ parts = append(parts,
228+ volumeOpt("/var/run/docker.sock", "/var/run/docker.sock"),
229+ volumeOpt(temp, "/github/runner_temp"),
230+ volumeOpt(home, "/github/home"),
231+ volumeOpt(workflowDir, "/github/workflow"),
232+ volumeOpt(fcDir, "/github/file_commands"),
233+ volumeOpt(workspace, "/github/workspace"),
234+ raw(image),
235+ dockerPart{line: argLine, args: argList})
236+ cmd := j.newDockerCmd("run", parts...)
237+ j.out(num, "##[command]"+cmd.line)
238+ // The docker process gets the container variables on top of its own
239+ // environment: "-e NAME" reads them from there.
240+ // (ProcessInvoker gets ContainerEnvironmentVariables: the translated values,
241+ // and nothing from the contexts on top.)
242+ s.rawEnvList = j.dockerProcessEnv(cenv)
243+ return j.runProc(s, cmd.argv[0], workspace, cmd.argv, "Docker Action run completed with exit code %d", cancel)
244+}
245+
246+// dockerArgs is the "args" of the entrypoint: the manifest's list, evaluated and
247+// quoted, or the "args" input as it is (ContainerActionHandler.cs:133-161).
248+func (j *job) dockerArgs(r *action.Runs, in *inputSet, te template.Env) (line string, list []string, err error) {
249+ if r != nil && r.Args != nil {
250+ v, err := template.Evaluate(context.Background(), r.Args, te)
251+ if err != nil {
252+ return "", nil, err
253+ }
254+ a, _ := v.(*expr.Array)
255+ if a != nil {
256+ for _, it := range a.Items() {
257+ s := expr.CoerceString(it)
258+ line += " " + quoteArg(s)
259+ list = append(list, s)
260+ }
261+ }
262+ return line, list, nil
263+ }
264+ raw, _ := in.Get("args")
265+ return raw, splitArgs(raw), nil
266+}
267+
268+// cancelledStep logs the cancellation of a step that was running docker, unless
269+// the step timed out: then StepsRunner logs the timeout message instead
270+// (stepRun.logCanceled).
271+func (j *job) cancelledStep(s *stepRun) stepResult {
272+ s.logCanceled()
273+ return resCanceled
274+}
275+
276+// done reports whether a cancellation channel is closed (a nil one never is).
277+func done(c <-chan struct{}) bool {
278+ select {
279+ case <-c:
280+ return true
281+ default:
282+ return false
283+ }
284+}
285+
286+func shortID() string { return strings.ReplaceAll(newImageTag(""), ":", "")[:6] }
287+
288+// dockerProcessEnv is the environment of the docker CLI: the one of the runner
289+// and the translated container variables (DockerCommandManager.DockerRun passes
290+// ContainerEnvironmentVariables as the environment of the process).
291+func (j *job) dockerProcessEnv(cenv *ordered) []string {
292+ penv := newOrdered()
293+ for _, kv := range j.baseEnv {
294+ if k, v, ok := strings.Cut(kv, "="); ok {
295+ penv.Set(k, v)
296+ }
297+ }
298+ for _, k := range cenv.Keys() {
299+ v, _ := cenv.Get(k)
300+ penv.Set(k, v)
301+ }
302+ penv.Set(ProcessTrackingID, j.trackID)
303+ var list []string
304+ for _, k := range penv.Keys() {
305+ v, _ := penv.Get(k)
306+ list = append(list, k+"="+v)
307+ }
308+ return list
309+}
A internal/executor/actions_docker_cmd.go +197 -0
@@ -0,0 +1,197 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "crypto/sha256"
7+ "encoding/hex"
8+ "os"
9+ "os/exec"
10+ "regexp"
11+ "strings"
12+
13+ "github.com/google/uuid"
14+)
15+
16+// dockerPath is the docker CLI (DockerCommandManager.Initialize: which docker).
17+func (j *job) dockerPath() string {
18+ if p := j.cfg.Actions.DockerPath; p != "" {
19+ return p
20+ }
21+ if p, err := exec.LookPath("docker"); err == nil {
22+ return p
23+ }
24+ return "/usr/bin/docker"
25+}
26+
27+// dockerLabel is DockerInstanceLabel: six hex characters that identify the
28+// runner (the first characters of the SHA-256 of its .runner file). The
29+// sandbox has one runner per job; the job's tracking id stands in.
30+func (j *job) dockerLabel() string {
31+ h := sha256.Sum256([]byte(j.trackID))
32+ return hex.EncodeToString(h[:])[:6]
33+}
34+
35+// escapeDockerArg is DockerUtil.EscapeString: the string wrapped in quotes with
36+// the backslashes before a quote doubled (and at the end), as the runner writes
37+// the arguments of its docker commands (it echoes them, so the log has them).
38+var (
39+ quoteEscape = regexp.MustCompile(`(\\*)"`)
40+ endOfStringEscape = regexp.MustCompile(`(\\+)$`)
41+)
42+
43+func escapeDockerArg(v string) string {
44+ if v == "" {
45+ return ""
46+ }
47+ v = quoteEscape.ReplaceAllString(v, `$1$1\"`)
48+ v = endOfStringEscape.ReplaceAllString(v, `$1$1`)
49+ return `"` + v + `"`
50+}
51+
52+// dockerCmd is a docker command: the line the runner echoes and the arguments
53+// that are run.
54+type dockerCmd struct {
55+ line string
56+ argv []string
57+}
58+
59+// newDockerCmd builds "docker <command> <options>" the way the runner does
60+// (ExecuteDockerCommandAsync: command and options joined by a space and
61+// trimmed) and keeps the argv apart, so that nothing is re-split.
62+func (j *job) newDockerCmd(command string, parts ...dockerPart) dockerCmd {
63+ lines := []string{}
64+ argv := []string{j.dockerPath(), command}
65+ for _, p := range parts {
66+ lines = append(lines, p.line)
67+ argv = append(argv, p.args...)
68+ }
69+ line := strings.TrimSpace(command + " " + strings.Join(lines, " "))
70+ return dockerCmd{line: j.dockerPath() + " " + line, argv: argv}
71+}
72+
73+// dockerPart is one piece of a docker command: its text in the echoed line and
74+// its arguments.
75+type dockerPart struct {
76+ line string
77+ args []string
78+}
79+
80+func raw(args ...string) dockerPart { return dockerPart{line: strings.Join(args, " "), args: args} }
81+
82+// envOpt is CreateEscapedOption("-e", key): the value is read from the
83+// environment of the docker process.
84+func envOpt(key string) dockerPart {
85+ return dockerPart{line: "-e " + escapeDockerArg(key), args: []string{"-e", key}}
86+}
87+
88+func entrypointOpt(ep string) dockerPart {
89+ return dockerPart{line: `--entrypoint "` + ep + `"`, args: []string{"--entrypoint", ep}}
90+}
91+
92+func volumeOpt(src, dst string) dockerPart {
93+ return dockerPart{
94+ line: `-v "` + strings.ReplaceAll(src, `"`, `\"`) + `":"` + strings.ReplaceAll(dst, `"`, `\"`) + `"`,
95+ args: []string{"-v", src + ":" + dst},
96+ }
97+}
98+
99+// sanitizeName is NameValidation.Sanitize: letters, digits (not first) and "_".
100+func sanitizeName(s string) string {
101+ var b strings.Builder
102+ for _, r := range s {
103+ switch {
104+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_':
105+ b.WriteRune(r)
106+ case r >= '0' && r <= '9' && b.Len() > 0:
107+ b.WriteRune(r)
108+ }
109+ }
110+ return b.String()
111+}
112+
113+func newImageTag(label string) string {
114+ return label + ":" + strings.ReplaceAll(uuid.NewString(), "-", "")
115+}
116+
117+// splitArgs splits a command line like .NET does for the arguments of a
118+// process: spaces separate, double quotes group, a backslash before a quote
119+// escapes it (ArgUtil.SplitArguments is not needed: ProcessInvoker passes the
120+// string to the platform, which splits it with these rules).
121+func splitArgs(s string) []string {
122+ var out []string
123+ var cur strings.Builder
124+ in, started := false, false
125+ for i := 0; i < len(s); i++ {
126+ c := s[i]
127+ switch {
128+ case c == '\\':
129+ n := 0
130+ for i < len(s) && s[i] == '\\' {
131+ n++
132+ i++
133+ }
134+ if i < len(s) && s[i] == '"' {
135+ cur.WriteString(strings.Repeat(`\`, n/2))
136+ if n%2 == 1 {
137+ cur.WriteByte('"')
138+ } else {
139+ in = !in
140+ }
141+ } else {
142+ cur.WriteString(strings.Repeat(`\`, n))
143+ i--
144+ }
145+ started = true
146+ case c == '"':
147+ in = !in
148+ started = true
149+ case (c == ' ' || c == '\t') && !in:
150+ if started {
151+ out = append(out, cur.String())
152+ cur.Reset()
153+ started = false
154+ }
155+ default:
156+ cur.WriteByte(c)
157+ started = true
158+ }
159+ }
160+ if started {
161+ out = append(out, cur.String())
162+ }
163+ return out
164+}
165+
166+// runDockerCmd echoes the command (##[command], ExecutionContext.Command) and
167+// runs it, sending every line of its output to the log as is: the output of
168+// docker is not interpreted as workflow commands (ExecuteDockerCommandAsync
169+// writes it with context.Output). It returns the exit code.
170+func (j *job) runDockerCmd(s *stepRun, c dockerCmd, dir string, env []string, cancel <-chan struct{}) (int, error) {
171+ j.out(s.num, "##[command]"+c.line)
172+ if env == nil {
173+ env = j.processEnv(newOrdered())
174+ }
175+ res, err := runProcess(procSpec{
176+ Argv: c.argv, Dir: dir, Env: env, Credential: j.cfg.Credential,
177+ OnLine: func(line string) { j.write(s.num, line) },
178+ Cancel: cancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter, WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
179+ })
180+ if err != nil {
181+ return 0, err
182+ }
183+ if res.Cancelled {
184+ return res.ExitCode, errCancelled
185+ }
186+ return res.ExitCode, nil
187+}
188+
189+// errCancelled is a docker command stopped by the cancellation of the step.
190+var errCancelled = ghErrorf("The operation was canceled.")
191+
192+func dirOrCwd(d string) string {
193+ if st, err := os.Stat(d); err == nil && st.IsDir() {
194+ return d
195+ }
196+ return ""
197+}
A internal/executor/actions_docker_test.go +231 -0
@@ -0,0 +1,231 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+// fakeDocker is a docker CLI that appends its arguments, one per line between
14+// "--" markers, and the values of the variables it was told to look at, to a
15+// file. pull and build succeed, "run" prints what it was given.
16+func fakeDocker(t *testing.T, failPulls int) (path, logFile string) {
17+ t.Helper()
18+ dir := t.TempDir()
19+ logFile = filepath.Join(dir, "calls")
20+ counter := filepath.Join(dir, "pulls")
21+ script := `#!/bin/sh
22+{ echo "--"; for a in "$@"; do printf "%s\n" "$a"; done; } >> ` + logFile + `
23+case "$1" in
24+pull)
25+ n=$(cat ` + counter + ` 2>/dev/null || echo 0); n=$((n+1)); echo $n > ` + counter + `
26+ if [ "$n" -le ` + string(rune('0'+failPulls)) + ` ]; then echo "pull failed"; exit 1; fi
27+ echo "pulled $2" ;;
28+build) echo "built" ;;
29+run)
30+ echo "RUN-ENV INPUT_WHO=$INPUT_WHO HOME=$HOME GITHUB_ENV=$GITHUB_ENV GITHUB_WORKSPACE=$GITHUB_WORKSPACE FOO=$FOO"
31+ echo "::set-output name=x::y" ;;
32+esac
33+exit 0
34+`
35+ if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil {
36+ t.Fatal(err)
37+ }
38+ return filepath.Join(dir, "docker"), logFile
39+}
40+
41+func calls(t *testing.T, logFile string) [][]string {
42+ t.Helper()
43+ b, _ := os.ReadFile(logFile)
44+ var out [][]string
45+ for _, blk := range strings.Split(string(b), "--\n") {
46+ if blk = strings.TrimRight(blk, "\n"); blk != "" {
47+ out = append(out, strings.Split(blk, "\n"))
48+ }
49+ }
50+ return out
51+}
52+
53+func dockerRun(t *testing.T, tree files, yaml string, failPulls int) (*recorder, Result, [][]string, *Layout) {
54+ t.Helper()
55+ d, lf := fakeDocker(t, failPulls)
56+ var lay *Layout
57+ rec, res := actionRun(t, tree, yaml, func(c *Config) {
58+ c.Actions.DockerPath = d
59+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
60+ lay = c.Layout
61+ })
62+ return rec, res, calls(t, lf), lay
63+}
64+
65+func TestDockerHubImageAction(t *testing.T) {
66+ rec, res, cs, lay := dockerRun(t, files{}, " - uses: docker://alpine:3.20\n with:\n entryPoint: /bin/sh\n args: -c \"echo hi there\"\n env:\n FOO: bar\n", 0)
67+ if res.Conclusion != ConclusionSuccess {
68+ t.Fatalf("%+v %v", res, rec.names())
69+ }
70+ want := []string{"Set up job", "Pull alpine:3.20", "Run alpine:3.20", "Complete job"}
71+ if got := rec.names(); strings.Join(got, "|") != strings.Join(want, "|") {
72+ t.Fatalf("steps %v", got)
73+ }
74+ pull := logOf(rec.steps[1])
75+ if !strings.HasPrefix(pull, "##[group]Pull down action image 'alpine:3.20'\n##[command]") || !strings.Contains(pull, " pull alpine:3.20\npulled alpine:3.20\n##[endgroup]") {
76+ t.Errorf("pull:\n%s", pull)
77+ }
78+ run := logOf(rec.steps[2])
79+ if !strings.Contains(run, "##[group]Run docker://alpine:3.20") || !strings.Contains(run, " entryPoint: /bin/sh") {
80+ t.Errorf("run:\n%s", run)
81+ }
82+ if len(cs) != 2 {
83+ t.Fatalf("calls %v", cs)
84+ }
85+ r := cs[1]
86+ has := func(a ...string) bool {
87+ for i := 0; i+len(a) <= len(r); i++ {
88+ if strings.Join(r[i:i+len(a)], "\x00") == strings.Join(a, "\x00") {
89+ return true
90+ }
91+ }
92+ return false
93+ }
94+ ws, tmp := filepath.Join(lay.Workspace), lay.Temp
95+ for _, w := range [][]string{
96+ {"--workdir", "/github/workspace"}, {"--rm"}, {"-e", "INPUT_ENTRYPOINT"}, {"-e", "FOO"}, {"-e", "GITHUB_ACTIONS=true"}, {"-e", "CI=true"},
97+ {"--entrypoint", "/bin/sh"}, {"-v", "/var/run/docker.sock:/var/run/docker.sock"}, {"-v", tmp + ":/github/runner_temp"},
98+ {"-v", tmp + "/_github_home:/github/home"}, {"-v", tmp + "/_github_workflow:/github/workflow"},
99+ {"-v", tmp + "/_runner_file_commands:/github/file_commands"}, {"-v", ws + ":/github/workspace"},
100+ {"alpine:3.20", "-c", "echo hi there"},
101+ } {
102+ if !has(w...) {
103+ t.Errorf("run lacks %q:\n%s", w, strings.Join(r, "\n"))
104+ }
105+ }
106+ // The docker CLI gets the variables as the container sees them.
107+ if !strings.Contains(run, "FOO=bar") || !strings.Contains(run, "HOME=/github/home") || !strings.Contains(run, "GITHUB_ENV=/github/file_commands/set_env_") ||
108+ !strings.Contains(run, "GITHUB_WORKSPACE=/github/workspace ") {
109+ t.Errorf("env of the docker process:\n%s", run)
110+ }
111+ // The echoed command line has the runner's quoting.
112+ if !strings.Contains(run, `--entrypoint "/bin/sh"`) || !strings.Contains(run, `-e "FOO"`) || !strings.Contains(run, ` alpine:3.20 -c "echo hi there"`) {
113+ t.Errorf("command line:\n%s", run)
114+ }
115+ if !strings.Contains(run, "-v \""+tmp+"\":\"/github/runner_temp\"") {
116+ t.Errorf("volume quoting:\n%s", run)
117+ }
118+ // Inside the container the file command paths are the mounted ones; here the
119+ // fake docker saw the host's, and what "-e" passes is translated.
120+ if !strings.Contains(strings.Join(r, "\n"), "-e\nGITHUB_ENV\n") {
121+ t.Errorf("GITHUB_ENV is passed by name:\n%s", strings.Join(r, "\n"))
122+ }
123+}
124+
125+func TestDockerPullRetries(t *testing.T) {
126+ rec, res, cs, _ := dockerRun(t, files{}, " - uses: docker://busybox\n", 2)
127+ if res.Conclusion != ConclusionSuccess || len(cs) != 4 {
128+ t.Fatalf("%+v calls %d", res, len(cs))
129+ }
130+ if got := logOf(rec.steps[1]); strings.Count(got, "##[warning]Docker pull failed with exit code 1, back off 0.001 seconds before retry.") != 2 {
131+ t.Errorf("%s", got)
132+ }
133+ rec, res, _, _ = dockerRun(t, files{}, " - uses: docker://busybox\n", 5)
134+ if res.Conclusion != ConclusionFailure || rec.steps[1].Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[1]), "##[error]Docker pull failed with exit code 1") {
135+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
136+ }
137+ if len(rec.steps) != 4 || rec.steps[2].Conclusion != ConclusionSkipped {
138+ t.Errorf("a failed pull skips the step: %v", rec.names())
139+ }
140+}
141+
142+func TestDockerfileAction(t *testing.T) {
143+ tree := files{
144+ "act/action.yml": "name: d\ninputs:\n who:\n default: me\nruns:\n using: docker\n image: Dockerfile\n entrypoint: /entry.sh\n args:\n - hello\n - ${{ inputs.who }}\n - ''\n - 'a \"quoted\" one'\n env:\n ACT_ENV: ${{ inputs.who }}\n",
145+ "act/Dockerfile": "FROM alpine\n",
146+ }
147+ rec, res, cs, _ := dockerRun(t, tree, " - uses: ./act\n with:\n who: Ermite\n", 0)
148+ if res.Conclusion != ConclusionSuccess {
149+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
150+ }
151+ got := logOf(rec.step(t, "Run ./act"))
152+ // A local Dockerfile action builds in its own step (ContainerActionHandler.cs:64-84).
153+ if !strings.Contains(got, "##[group]Building docker image\nDockerfile for action: '") || !strings.Contains(got, "##[command]") {
154+ t.Errorf("%s", got)
155+ }
156+ if len(cs) != 2 || cs[0][0] != "build" || cs[1][0] != "run" {
157+ t.Fatalf("calls %v", cs)
158+ }
159+ if cs[0][1] != "-t" || !strings.Contains(cs[0][2], ":") || cs[0][3] != "-f" || !strings.HasSuffix(cs[0][4], "/act/Dockerfile") || !strings.HasSuffix(cs[0][5], "/act") {
160+ t.Errorf("build args %v", cs[0])
161+ }
162+ run := strings.Join(cs[1], "\n")
163+ if !strings.Contains(run, "--entrypoint\n/entry.sh\n") || !strings.HasSuffix(run, "\nhello\nEr"+"mite\n\na \"quoted\" one") {
164+ t.Errorf("args:\n%s", run)
165+ }
166+ if !strings.Contains(got, ` "hello" "Ermite" "" "a \"quoted\" one"`) {
167+ t.Errorf("echoed args:\n%s", got)
168+ }
169+ if !strings.Contains(run, "-e\nACT_ENV\n") {
170+ t.Errorf("env of the manifest:\n%s", run)
171+ }
172+}
173+
174+func TestRepositoryDockerfileActionBuildsInSetup(t *testing.T) {
175+ forge := newForge(t, map[string][]byte{"/tarball/o/d/v1": tarOf(t, "o-d-x", files{"Dockerfile": "FROM alpine\n"})})
176+ d, lf := fakeDocker(t, 0)
177+ rec, res := actionRun(t, files{}, " - uses: o/d@v1\n - uses: o/d@v1\n", func(c *Config) {
178+ c.Actions.DockerPath = d
179+ c.Actions.Resolver = forge.resolver("")
180+ })
181+ if res.Conclusion != ConclusionSuccess {
182+ t.Fatalf("%+v %v", res, rec.names())
183+ }
184+ want := "Set up job|Build o/d@v1|Run o/d@v1|Run o/d@v1|Complete job"
185+ if got := strings.Join(rec.names(), "|"); got != want {
186+ t.Fatalf("%s", got)
187+ }
188+ if got := logOf(rec.steps[1]); !strings.HasPrefix(got, "##[group]Build container for action use: '") || !strings.Contains(got, "/o/d/v1/Dockerfile'.\n##[command]") || !strings.HasSuffix(got, "\nbuilt\n##[endgroup]") {
189+ t.Errorf("build step:\n%s", got)
190+ }
191+ if n := len(calls(t, lf)); n != 3 {
192+ t.Errorf("one build for the two steps, then two runs: %d calls", n)
193+ }
194+ // Both runs use the image the setup step built.
195+ cs := calls(t, lf)
196+ if cs[1][len(cs[1])-1] != cs[2][len(cs[2])-1] && cs[1][len(cs[1])-2] != cs[2][len(cs[2])-2] {
197+ t.Errorf("%v %v", cs[1], cs[2])
198+ }
199+}
200+
201+func TestEscapeDockerArg(t *testing.T) {
202+ for in, want := range map[string]string{
203+ "": "", "A": `"A"`, `a"b`: `"a\"b"`, `a\"b`: `"a\\\"b"`, `a\`: `"a\\"`, `a\\`: `"a\\\\"`, `a b`: `"a b"`,
204+ } {
205+ if got := escapeDockerArg(in); got != want {
206+ t.Errorf("%q: %q, want %q", in, got, want)
207+ }
208+ }
209+}
210+
211+func TestSplitArgs(t *testing.T) {
212+ for in, want := range map[string]string{
213+ ``: ``, `a b c`: `a|b|c`, `-c "echo hi"`: `-c|echo hi`, `a\"b`: `a"b`, `"" x`: `|x`, `a"b c"d`: `ab cd`,
214+ } {
215+ if got := strings.Join(splitArgs(in), "|"); got != want {
216+ t.Errorf("%q: %q, want %q", in, got, want)
217+ }
218+ }
219+}
220+
221+func TestTranslatePath(t *testing.T) {
222+ maps := []pathMap{{"/w/ws", "/github/workspace"}, {"/w/tmp/_github_home", "/github/home"}, {"/w/tmp", "/github/runner_temp"}}
223+ for in, want := range map[string]string{
224+ "/w/ws": "/github/workspace", "/w/ws/a/b": "/github/workspace/a/b", "/w/tmp/_github_home/x": "/github/home/x",
225+ "/w/tmp/f": "/github/runner_temp/f", "/w/wsx": "/w/wsx", "x": "x", "": "",
226+ } {
227+ if got := translatePath(maps, in); got != want {
228+ t.Errorf("%q: %q, want %q", in, got, want)
229+ }
230+ }
231+}
A internal/executor/actions_download.go +257 -0
@@ -0,0 +1,257 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "math/rand/v2"
12+ "net/http"
13+ "os"
14+ "path/filepath"
15+ "slices"
16+ "time"
17+
18+ "github.com/google/uuid"
19+
20+ "bnema.dev/ermite-runner/internal/urlorigin"
21+)
22+
23+// Constants of ActionManager.DownloadRepositoryArchive (ActionManager.cs:1600-1760).
24+const (
25+ downloadAttempts = 3
26+ downloadTimeout = 20 * time.Minute
27+ // maxDownloadBytes bounds an archive (the runner has no bound).
28+ maxDownloadBytes = 1 << 30
29+)
30+
31+// noBackoffEnv is the runner's switch for tests (ActionManager.cs:1739).
32+const noBackoffEnv = "_GITHUB_ACTION_DOWNLOAD_NO_BACKOFF"
33+
34+// errActionNotFound is ActionNotFoundException (ActionNotFoundException.cs:28).
35+type errActionNotFound struct{ url string }
36+
37+func (e errActionNotFound) Error() string {
38+ return fmt.Sprintf("An action could not be found at the URI '%s'", e.url)
39+}
40+
41+// errAccessDenied is AccessDeniedException; the request id is empty (ermite
42+// has none) and the runner still prints its parentheses.
43+type errAccessDenied struct{ url string }
44+
45+func (e errAccessDenied) Error() string { return fmt.Sprintf("Access denied to '%s' ()", e.url) }
46+
47+// originOf is the canonical origin of a URL (urlorigin.Canonical): the one the
48+// host and the executor both compare, scheme, host and port with the default
49+// port filled in, "" for anything that is not an absolute http(s) URL.
50+func originOf(raw string) string { return urlorigin.Canonical(raw) }
51+
52+// tokenAllowed reports whether the token of ra may be sent to its tarball URL:
53+// the URL is on exactly the origin the host named, compared in canonical form
54+// (default ports filled in, IPv6 hosts in brackets).
55+func tokenAllowed(ra ResolvedAction) bool {
56+ o := originOf(ra.AuthOrigin)
57+ return ra.Token != "" && o != "" && o == originOf(ra.TarballURL)
58+}
59+
60+// archiveClient returns the client that downloads the archives. It follows
61+// redirects (GitHub's codeload does redirect) but never carries credentials
62+// across an origin: the Authorization header is dropped as soon as the scheme,
63+// the host or the port changes, and a redirect from https to http is refused.
64+// (net/http alone would keep the header for subdomains and ignore the scheme and
65+// the port.) Tests may supply their own transport through Actions.HTTPClient;
66+// its redirect policy is replaced.
67+func (j *job) archiveClient() *http.Client {
68+ c := &http.Client{}
69+ if base := j.cfg.Actions.HTTPClient; base != nil {
70+ cp := *base
71+ c = &cp
72+ }
73+ c.CheckRedirect = func(req *http.Request, via []*http.Request) error {
74+ if len(via) >= 10 {
75+ return errors.New("stopped after 10 redirects")
76+ }
77+ // Go builds every redirected request from the headers of the first one
78+ // (copying Authorization when the host matches), so the header must be
79+ // removed again on every hop that is not on the origin the token was meant
80+ // for: the first request's. Once the chain left that origin, it never gets
81+ // the header back, even if a later hop returns to it (A -> B -> A would
82+ // otherwise be re-authorised by the copy).
83+ origin := originOf(via[0].URL.String())
84+ chain := slices.Concat(via[1:], []*http.Request{req}) // not append: via belongs to the client
85+ for i, r := range chain {
86+ prev := via[i]
87+ if prev.URL.Scheme == "https" && r.URL.Scheme != "https" {
88+ return fmt.Errorf("refusing to follow a redirect from https to %s", r.URL.Scheme)
89+ }
90+ }
91+ for _, r := range chain {
92+ if originOf(r.URL.String()) != origin {
93+ req.Header.Del("Authorization")
94+ break
95+ }
96+ }
97+ return nil
98+ }
99+ return c
100+}
101+
102+// sleepBackoff waits a random 10 to 30 seconds (BackoffTimerHelper.
103+// GetRandomBackoff), or what the test hook says.
104+func (j *job) sleepBackoff(ctx context.Context) time.Duration {
105+ if d := j.cfg.Actions.Backoff; d != nil {
106+ return d()
107+ }
108+ return time.Duration(10+rand.Int64N(21)) * time.Second
109+}
110+
111+// downloadArchive saves the archive of an action in file, retrying like the
112+// runner: three attempts, a warning after each failure, a 404 or a 403 at once.
113+// The final error is the one of the last attempt, which is what the runner
114+// shows (PrepareActionsAsync prints the inner exception of
115+// FailedToDownloadActionException).
116+func (j *job) downloadArchive(ctx context.Context, num int, ra ResolvedAction, file string) error {
117+ url := ra.TarballURL
118+ var last error
119+ for attempt := 1; attempt <= downloadAttempts; attempt++ {
120+ err := j.fetch(ctx, ra, file)
121+ if err == nil {
122+ return nil
123+ }
124+ last = err
125+ var nf errActionNotFound
126+ var ad errAccessDenied
127+ if ctx.Err() != nil || errors.As(err, &ad) {
128+ return err
129+ }
130+ if errors.As(err, &nf) {
131+ return err
132+ }
133+ if attempt == downloadAttempts {
134+ break
135+ }
136+ j.tagged(num, "##[warning]", fmt.Sprintf("Failed to download action '%s'. Error: %s ", url, err.Error()))
137+ if os.Getenv(noBackoffEnv) == "" {
138+ d := j.sleepBackoff(ctx)
139+ j.tagged(num, "##[warning]", fmt.Sprintf("Back off %v seconds before retry.", d.Seconds()))
140+ select {
141+ case <-time.After(d):
142+ case <-ctx.Done():
143+ return ctx.Err()
144+ }
145+ }
146+ }
147+ return last
148+}
149+
150+func (j *job) fetch(ctx context.Context, ra ResolvedAction, file string) error {
151+ url, token := ra.TarballURL, ra.Token
152+ // The token goes only to the origin the host named, never by the URL alone.
153+ if !tokenAllowed(ra) {
154+ token = ""
155+ }
156+ ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
157+ defer cancel()
158+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
159+ if err != nil {
160+ return err
161+ }
162+ if token != "" {
163+ req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)))
164+ }
165+ req.Header.Set("User-Agent", "ermite-runner")
166+ resp, err := j.archiveClient().Do(req)
167+ if err != nil {
168+ return err
169+ }
170+ defer func() { _ = resp.Body.Close() }()
171+ switch {
172+ case resp.StatusCode == http.StatusNotFound:
173+ return errActionNotFound{url}
174+ case resp.StatusCode == http.StatusForbidden:
175+ return errAccessDenied{url}
176+ case resp.StatusCode < 200 || resp.StatusCode > 299:
177+ // HttpResponseMessage.EnsureSuccessStatusCode.
178+ return ghErrorf("Response status code does not indicate success: %d (%s).", resp.StatusCode, http.StatusText(resp.StatusCode))
179+ }
180+ f, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
181+ if err != nil {
182+ return err
183+ }
184+ n, err := io.Copy(f, io.LimitReader(resp.Body, maxDownloadBytes+1))
185+ if cerr := f.Close(); err == nil {
186+ err = cerr
187+ }
188+ if err == nil && n > maxDownloadBytes {
189+ err = errors.New("the archive is larger than 1 GiB")
190+ }
191+ return err
192+}
193+
194+// watermark is the file that says an action was downloaded
195+// (ActionManager.GetWatermarkFilePath).
196+func watermark(dir string) string { return dir + ".completed" }
197+
198+// downloadAction downloads and unpacks one action into dir
199+// (ActionManager.DownloadRepositoryActionAsync): in a temporary directory, the
200+// archive's single top directory stripped, renamed to dir on success, and
201+// marked with the watermark.
202+func (j *job) downloadAction(ctx context.Context, num int, ra ResolvedAction, name, ref, dir string) error {
203+ if _, err := os.Stat(watermark(dir)); err == nil {
204+ j.debug(num, fmt.Sprintf("Action '%s@%s' already downloaded at '%s'.", name, ref, dir))
205+ return nil
206+ }
207+ j.out(num, fmt.Sprintf("Download action repository '%s@%s' (SHA:%s)", name, ref, ra.ResolvedSHA))
208+
209+ actions := j.state.Layout().Actions
210+ tmp := filepath.Join(actions, "_temp_"+uuid.NewString())
211+ if err := os.MkdirAll(tmp, 0o755); err != nil {
212+ return err
213+ }
214+ defer func() { _ = os.RemoveAll(tmp) }()
215+ archive := filepath.Join(tmp, uuid.NewString()+".tar.gz")
216+ if err := j.downloadArchive(ctx, num, ra, archive); err != nil {
217+ return err
218+ }
219+ staging := filepath.Join(tmp, "_staging")
220+ if err := os.Mkdir(staging, 0o755); err != nil {
221+ return err
222+ }
223+ af, err := os.Open(archive)
224+ if err != nil {
225+ return err
226+ }
227+ var owner *[2]int
228+ if c := j.cfg.Credential; c != nil {
229+ owner = &[2]int{int(c.Uid), int(c.Gid)}
230+ }
231+ // The top directory is stripped while extracting, so that links are checked
232+ // against the tree they will live in (see extractTarball).
233+ err = extractTarball(ctx, af, staging, owner)
234+ _ = af.Close()
235+ if err != nil {
236+ return ghErrorf("Can't extract the archive of action %s@%s: %v", name, ref, err)
237+ }
238+ _ = os.RemoveAll(dir)
239+ if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
240+ return err
241+ }
242+ if err := os.Rename(staging, dir); err != nil {
243+ return err
244+ }
245+ j.debug(num, fmt.Sprintf("Unwrap the archive to '%s'", dir))
246+ if err := os.WriteFile(watermark(dir), []byte(j.now().UTC().Format(time.DateTime)), 0o644); err != nil {
247+ return err
248+ }
249+ j.own(dir)
250+ _ = filepath.WalkDir(dir, func(p string, _ os.DirEntry, err error) error {
251+ if err == nil {
252+ j.own(p)
253+ }
254+ return nil
255+ })
256+ return nil
257+}
A internal/executor/actions_extract.go +322 -0
@@ -0,0 +1,322 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "archive/tar"
7+ "compress/gzip"
8+ "context"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "io/fs"
13+ "os"
14+ "path"
15+ "path/filepath"
16+ "strings"
17+)
18+
19+// Limits of an action archive. The runner has none (it runs tar); these keep a
20+// hostile archive from filling the sandbox. They are variables for the tests.
21+var (
22+ maxArchiveEntries = 200_000
23+ // maxArchiveBytes bounds the sizes of the regular files; maxStreamBytes the
24+ // whole decompressed tar stream, headers and PAX/GNU long-name records
25+ // included, which the entry and size counters do not see.
26+ maxArchiveBytes = int64(2 << 30)
27+ maxStreamBytes = maxArchiveBytes + 64<<20
28+)
29+
30+// errArchive is returned for an archive that is not safe or not valid. Its text
31+// is ermite's: GitHub's runner runs tar, which accepts absolute paths and links
32+// out of the tree (Runner.Worker/ActionManager.cs:1358-1394).
33+type errArchive string
34+
35+func (e errArchive) Error() string { return string(e) }
36+
37+// capReader fails once more than n bytes were read.
38+type capReader struct {
39+ r io.Reader
40+ n int64
41+}
42+
43+func (c *capReader) Read(p []byte) (int, error) {
44+ if c.n <= 0 {
45+ return 0, errArchive("the archive is too large once unpacked")
46+ }
47+ if int64(len(p)) > c.n {
48+ p = p[:c.n]
49+ }
50+ n, err := c.r.Read(p)
51+ c.n -= int64(n)
52+ return n, err
53+}
54+
55+// extractTarball unpacks a gzip-compressed tar into dir, which must exist and
56+// be empty, and strips the single top directory of the archive (GitHub's
57+// archives have exactly one, ActionManager.cs:1380-1393): the content lands in
58+// dir itself, so no file moves afterwards and a link that is valid here stays
59+// valid. Every name is checked: no absolute path, no "..", one top directory,
60+// no link whose target leaves dir, and everything is written through an
61+// os.Root, so that a link planted by an earlier entry cannot redirect a later
62+// one. The decompressed stream is capped, and ctx is checked between entries.
63+// Links are validated at install time: a running step can change the action
64+// tree like any file of its own sandbox, so this is not a confinement guarantee
65+// for what happens later.
66+// owner, when set, is the uid and gid of the files.
67+func extractTarball(ctx context.Context, r io.Reader, dir string, owner *[2]int) error {
68+ zr, err := gzip.NewReader(r)
69+ if err != nil {
70+ return errArchive("the archive is not a gzip file: " + err.Error())
71+ }
72+ defer func() { _ = zr.Close() }()
73+ root, err := os.OpenRoot(dir)
74+ if err != nil {
75+ return err
76+ }
77+ defer func() { _ = root.Close() }()
78+ tr := tar.NewReader(&capReader{r: zr, n: maxStreamBytes})
79+ entries, total, top := 0, int64(0), ""
80+ for {
81+ if err := ctx.Err(); err != nil {
82+ return err
83+ }
84+ h, err := tr.Next()
85+ if errors.Is(err, io.EOF) {
86+ return verifyLinks(ctx, root, dir)
87+ }
88+ if err != nil {
89+ var ea errArchive
90+ if errors.As(err, &ea) {
91+ return ea
92+ }
93+ return errArchive("the archive is not a valid tar file: " + err.Error())
94+ }
95+ if entries++; entries > maxArchiveEntries {
96+ return errArchive("the archive has too many entries")
97+ }
98+ if total += h.Size; total > maxArchiveBytes {
99+ return errArchive("the archive is too large once unpacked")
100+ }
101+ if err := extractEntry(root, tr, h, owner, &top); err != nil {
102+ return err
103+ }
104+ }
105+}
106+
107+// stripTop validates the name of an entry, drops its top directory and returns
108+// the rest, clean and relative to the final root ("" for the top directory
109+// itself). All entries must share one top directory.
110+func stripTop(name string, top *string) (string, error) {
111+ c, err := cleanEntry(name)
112+ if err != nil {
113+ return "", err
114+ }
115+ if c == "" {
116+ return "", nil
117+ }
118+ first, rest, hasRest := strings.Cut(c, "/")
119+ if *top == "" {
120+ *top = first
121+ } else if first != *top {
122+ return "", errArchive(fmt.Sprintf("the archive has more than one top-level directory: %q and %q", *top, first))
123+ }
124+ if !hasRest {
125+ return "", nil
126+ }
127+ return rest, nil
128+}
129+
130+// cleanEntry validates the name of an entry and returns it clean and relative.
131+func cleanEntry(name string) (string, error) {
132+ if strings.ContainsRune(name, 0) || strings.HasPrefix(name, "/") {
133+ return "", errArchive(fmt.Sprintf("the archive has an absolute or invalid path: %q", name))
134+ }
135+ for _, seg := range strings.Split(name, "/") {
136+ if seg == ".." {
137+ return "", errArchive(fmt.Sprintf("the archive has a path that leaves its directory: %q", name))
138+ }
139+ }
140+ c := path.Clean(name)
141+ if c == "." {
142+ return "", nil
143+ }
144+ return c, nil
145+}
146+
147+// linkInside reports whether a link at name pointing to target stays in the
148+// tree: target is relative to the directory of name for a symlink, and to the
149+// root for a hard link.
150+func linkInside(name, target string, hard bool) bool {
151+ if target == "" || strings.HasPrefix(target, "/") || strings.ContainsRune(target, 0) {
152+ return false
153+ }
154+ base := path.Dir(name)
155+ if hard {
156+ base = "."
157+ }
158+ t := path.Join(base, target)
159+ return t != ".." && !strings.HasPrefix(t, "../")
160+}
161+
162+func extractEntry(root *os.Root, tr *tar.Reader, h *tar.Header, owner *[2]int, top *string) error {
163+ switch h.Typeflag {
164+ case tar.TypeXGlobalHeader, tar.TypeXHeader:
165+ return nil // git archive's pax_global_header: the commit id
166+ }
167+ name, err := stripTop(h.Name, top)
168+ if err != nil {
169+ return err
170+ }
171+ if name == "" {
172+ if h.Typeflag != tar.TypeDir {
173+ return errArchive(fmt.Sprintf("the archive has a file next to its top-level directory: %q", h.Name))
174+ }
175+ return nil
176+ }
177+ perm := os.FileMode(h.Mode) & 0o777
178+ chown := func() error {
179+ if owner == nil {
180+ return nil
181+ }
182+ return root.Lchown(name, owner[0], owner[1])
183+ }
184+ if dir := path.Dir(name); dir != "." {
185+ if err := root.MkdirAll(dir, 0o755); err != nil {
186+ return errArchive(fmt.Sprintf("cannot create the directory of %q: %v", name, err))
187+ }
188+ }
189+ switch h.Typeflag {
190+ case tar.TypeDir:
191+ if err := root.MkdirAll(name, 0o755); err != nil {
192+ return err
193+ }
194+ if err := root.Chmod(name, perm|0o700); err != nil {
195+ return err
196+ }
197+ return chown()
198+ case tar.TypeReg:
199+ _ = root.Remove(name)
200+ f, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm|0o600)
201+ if err != nil {
202+ return errArchive(fmt.Sprintf("cannot create %q: %v", name, err))
203+ }
204+ if _, err := io.Copy(f, tr); err != nil {
205+ _ = f.Close()
206+ return errArchive("the archive is not a valid tar file: " + err.Error())
207+ }
208+ if err := f.Close(); err != nil {
209+ return err
210+ }
211+ if err := root.Chmod(name, perm|0o600); err != nil {
212+ return err
213+ }
214+ return chown()
215+ case tar.TypeSymlink:
216+ // Only the shape is checked here: where the link leads depends on the links
217+ // that come before and after it, so verifyLinks resolves every symlink of
218+ // the finished tree.
219+ if h.Linkname == "" || strings.HasPrefix(h.Linkname, "/") || strings.ContainsRune(h.Linkname, 0) {
220+ return errArchive(fmt.Sprintf("the archive has a link that points outside of it: %q -> %q", name, h.Linkname))
221+ }
222+ _ = root.Remove(name)
223+ if err := root.Symlink(h.Linkname, name); err != nil {
224+ return err
225+ }
226+ return chown()
227+ case tar.TypeLink:
228+ tgt, err := stripTop(h.Linkname, top)
229+ if err != nil || tgt == "" || !linkInside(name, tgt, true) {
230+ return errArchive(fmt.Sprintf("the archive has a link that points outside of it: %q -> %q", name, h.Linkname))
231+ }
232+ _ = root.Remove(name)
233+ return root.Link(tgt, name)
234+ }
235+ return nil // devices, fifos: skipped
236+}
237+
238+// maxLinkHops bounds the symbolic links followed to resolve one link (Linux
239+// stops at 40).
240+const maxLinkHops = 40
241+
242+// verifyLinks resolves every symbolic link of the extracted tree component by
243+// component, following the links met on the way, and rejects the archive when
244+// one leads out of the tree or loops. A lexical check is not enough: with
245+// "a -> ." a link "link -> a/../outside" looks like it stays in the tree, and
246+// leaves it. A link to something that does not exist is allowed (git archives
247+// have such links); the rest of its target is still resolved.
248+func verifyLinks(ctx context.Context, root *os.Root, dir string) error {
249+ return filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
250+ if err != nil {
251+ return err
252+ }
253+ if err := ctx.Err(); err != nil {
254+ return err
255+ }
256+ if d.Type()&fs.ModeSymlink == 0 {
257+ return nil
258+ }
259+ rel, err := filepath.Rel(dir, p)
260+ if err != nil {
261+ return err
262+ }
263+ target, err := root.Readlink(filepath.ToSlash(rel))
264+ if err != nil {
265+ return err
266+ }
267+ if err := resolveInside(root, path.Dir(filepath.ToSlash(rel)), target); err != nil {
268+ return errArchive(fmt.Sprintf("the archive has a link that points outside of it: %q -> %q (%v)", filepath.ToSlash(rel), target, err))
269+ }
270+ return nil
271+ })
272+}
273+
274+// resolveInside resolves target, relative to the directory dirRel of the tree
275+// (slash separated, "." for the root), the way the kernel would, and fails when
276+// the path leaves the root, is absolute, or needs more than maxLinkHops links.
277+func resolveInside(root *os.Root, dirRel, target string) error {
278+ var cur []string // resolved directory components, none of them a link
279+ if dirRel != "." && dirRel != "" {
280+ cur = strings.Split(dirRel, "/")
281+ }
282+ pending := strings.Split(target, "/")
283+ hops := 0
284+ for len(pending) > 0 {
285+ c := pending[0]
286+ pending = pending[1:]
287+ switch c {
288+ case "", ".":
289+ continue
290+ case "..":
291+ if len(cur) == 0 {
292+ return errors.New("leaves the tree")
293+ }
294+ cur = cur[:len(cur)-1]
295+ continue
296+ }
297+ p := strings.Join(append(append([]string(nil), cur...), c), "/")
298+ fi, err := root.Lstat(p)
299+ switch {
300+ case errors.Is(err, os.ErrNotExist):
301+ cur = append(cur, c)
302+ continue
303+ case err != nil:
304+ return err
305+ case fi.Mode()&fs.ModeSymlink == 0:
306+ cur = append(cur, c)
307+ continue
308+ }
309+ if hops++; hops > maxLinkHops {
310+ return errors.New("too many levels of links")
311+ }
312+ next, err := root.Readlink(p)
313+ if err != nil {
314+ return err
315+ }
316+ if strings.HasPrefix(next, "/") || next == "" {
317+ return errors.New("an absolute or empty link")
318+ }
319+ pending = append(strings.Split(next, "/"), pending...)
320+ }
321+ return nil
322+}
A internal/executor/actions_extract_test.go +257 -0
@@ -0,0 +1,257 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "archive/tar"
7+ "bytes"
8+ "compress/gzip"
9+ "context"
10+ "os"
11+ "path/filepath"
12+ "strings"
13+ "testing"
14+)
15+
16+type tent struct {
17+ name, body, link string
18+ typ byte
19+}
20+
21+func tarball(t *testing.T, ents ...tent) *bytes.Reader {
22+ t.Helper()
23+ var buf bytes.Buffer
24+ zw := gzip.NewWriter(&buf)
25+ tw := tar.NewWriter(zw)
26+ for _, e := range ents {
27+ typ := e.typ
28+ if typ == 0 {
29+ typ = tar.TypeReg
30+ }
31+ h := &tar.Header{Name: e.name, Typeflag: typ, Mode: 0o644, Linkname: e.link}
32+ if typ == tar.TypeReg {
33+ h.Size = int64(len(e.body))
34+ }
35+ if typ == tar.TypeDir {
36+ h.Mode = 0o755
37+ }
38+ if err := tw.WriteHeader(h); err != nil {
39+ t.Fatal(err)
40+ }
41+ if typ == tar.TypeReg {
42+ _, _ = tw.Write([]byte(e.body))
43+ }
44+ }
45+ _ = tw.Close()
46+ _ = zw.Close()
47+ return bytes.NewReader(buf.Bytes())
48+}
49+
50+func TestExtractTarball(t *testing.T) {
51+ dir := t.TempDir()
52+ err := extractTarball(context.Background(), tarball(t,
53+ tent{name: "o-r-abc/", typ: tar.TypeDir},
54+ tent{name: "o-r-abc/action.yml", body: "name: x"},
55+ tent{name: "o-r-abc/sub/main.js", body: "1"},
56+ tent{name: "o-r-abc/link", typ: tar.TypeSymlink, link: "sub/main.js"},
57+ tent{name: "o-r-abc/hard", typ: tar.TypeLink, link: "o-r-abc/sub/main.js"},
58+ ), dir, nil)
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ // The top directory is stripped: the content is in dir itself.
63+ for _, f := range []string{"action.yml", "link", "hard"} {
64+ if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
65+ t.Errorf("%s: %v", f, err)
66+ }
67+ }
68+ if b, _ := os.ReadFile(filepath.Join(dir, "link")); string(b) != "1" {
69+ t.Errorf("link: %q", b)
70+ }
71+ if _, err := os.Stat(filepath.Join(dir, "o-r-abc")); err == nil {
72+ t.Error("the top directory must be stripped")
73+ }
74+}
75+
76+func TestExtractTarballRejects(t *testing.T) {
77+ cases := map[string][]tent{
78+ "absolute": {{name: "/etc/passwd", body: "x"}},
79+ "dotdot": {{name: "t/a/../../x", body: "x"}},
80+ "symlink out": {{name: "t/a/l", typ: tar.TypeSymlink, link: "../../x"}},
81+ "symlink abs": {{name: "t/l", typ: tar.TypeSymlink, link: "/etc"}},
82+ "hardlink out": {{name: "t/l", typ: tar.TypeLink, link: "../x"}},
83+ "hardlink abs": {{name: "t/l", typ: tar.TypeLink, link: "/etc/passwd"}},
84+ "through link": {{name: "t/d", typ: tar.TypeSymlink, link: "."}, {name: "t/d/../../x", body: "x"}},
85+ "link then dir": {{name: "t/l", typ: tar.TypeSymlink, link: "a/.."}, {name: "t/l/../x", body: "x"}},
86+ "two tops": {{name: "t/a", body: "1"}, {name: "u/b", body: "2"}},
87+ "file beside": {{name: "t/", typ: tar.TypeDir}, {name: "loose", body: "x"}},
88+ "hardlink other": {{name: "t/a", body: "1"}, {name: "t/l", typ: tar.TypeLink, link: "u/a"}},
89+ }
90+ for name, ents := range cases {
91+ t.Run(name, func(t *testing.T) {
92+ parent := t.TempDir()
93+ dir := filepath.Join(parent, "dest")
94+ if err := os.Mkdir(dir, 0o755); err != nil {
95+ t.Fatal(err)
96+ }
97+ err := extractTarball(context.Background(), tarball(t, ents...), dir, nil)
98+ if err == nil {
99+ t.Fatal("accepted")
100+ }
101+ if _, statErr := os.Lstat(filepath.Join(parent, "x")); statErr == nil {
102+ t.Fatal("wrote outside")
103+ }
104+ })
105+ }
106+}
107+
108+// GO-001: "top/link -> ../outside" stays inside the staging directory of the
109+// extraction but escapes once the top directory is stripped. The target is
110+// resolved against the final root, whatever the location the files are
111+// extracted to, and the relocated tree has no link that leaves it.
112+func TestExtractTarballLinkEscapesStrippedRoot(t *testing.T) {
113+ // From top/sub/l, i.e. sub/l once the top directory is stripped.
114+ for link, ok := range map[string]bool{"../outside": true, "..": true, "../x": true, "../../outside": false, "../../top/x": false, "../../../x": false} {
115+ dir := filepath.Join(t.TempDir(), "stage")
116+ _ = os.Mkdir(dir, 0o755)
117+ err := extractTarball(context.Background(), tarball(t,
118+ tent{name: "top/x", body: "x"},
119+ tent{name: "top/sub/l", typ: tar.TypeSymlink, link: link},
120+ ), dir, nil)
121+ if (err == nil) != ok {
122+ t.Errorf("link %q: accepted=%v, want %v (%v)", link, err == nil, ok, err)
123+ }
124+ }
125+ // The direct case of the finding.
126+ dir := filepath.Join(t.TempDir(), "stage")
127+ _ = os.Mkdir(dir, 0o755)
128+ if err := extractTarball(context.Background(), tarball(t,
129+ tent{name: "top/link", typ: tar.TypeSymlink, link: "../outside"},
130+ ), dir, nil); err == nil {
131+ t.Fatal("top/link -> ../outside was accepted")
132+ }
133+ // A tree that was accepted keeps all its links inside after relocation.
134+ stage := filepath.Join(t.TempDir(), "stage")
135+ _ = os.Mkdir(stage, 0o755)
136+ if err := extractTarball(context.Background(), tarball(t,
137+ tent{name: "top/real", body: "r"},
138+ tent{name: "top/a/b/l", typ: tar.TypeSymlink, link: "../../real"},
139+ ), stage, nil); err != nil {
140+ t.Fatal(err)
141+ }
142+ final := filepath.Join(t.TempDir(), "final")
143+ if err := os.Rename(stage, final); err != nil {
144+ t.Fatal(err)
145+ }
146+ if b, err := os.ReadFile(filepath.Join(final, "a", "b", "l")); err != nil || string(b) != "r" {
147+ t.Fatalf("relocated link: %q %v", b, err)
148+ }
149+}
150+
151+func TestExtractTarballNotGzip(t *testing.T) {
152+ err := extractTarball(context.Background(), strings.NewReader("not gzip"), t.TempDir(), nil)
153+ if err == nil || !strings.Contains(err.Error(), "not a gzip") {
154+ t.Fatalf("%v", err)
155+ }
156+}
157+
158+// A symlink to a directory inside the tree must not let a later entry write
159+// outside it, even through a chain of links.
160+func TestExtractTarballLinkChain(t *testing.T) {
161+ parent := t.TempDir()
162+ dir := filepath.Join(parent, "dest")
163+ _ = os.Mkdir(dir, 0o755)
164+ err := extractTarball(context.Background(), tarball(t,
165+ tent{name: "t/a", typ: tar.TypeSymlink, link: "b"},
166+ tent{name: "t/b", typ: tar.TypeSymlink, link: "."},
167+ tent{name: "t/a/a/a/f", body: "x"},
168+ ), dir, nil)
169+ if err != nil {
170+ t.Fatal(err)
171+ }
172+ if b, _ := os.ReadFile(filepath.Join(dir, "f")); string(b) != "x" {
173+ t.Fatalf("f: %q", b)
174+ }
175+}
176+
177+// GO-004: PAX and GNU long-name records are not entries and have no Size in the
178+// header Next returns, so the counters do not see them. The decompressed stream
179+// is capped.
180+func TestExtractTarballMetadataFlood(t *testing.T) {
181+ old := maxStreamBytes
182+ maxStreamBytes = 256 << 10
183+ defer func() { maxStreamBytes = old }()
184+ var buf bytes.Buffer
185+ zw := gzip.NewWriter(&buf)
186+ tw := tar.NewWriter(zw)
187+ for i := 0; i < 400; i++ {
188+ // A PAX record per file with a 4 KiB value; the entries stay below
189+ // every counter.
190+ h := &tar.Header{Name: "t/f", Typeflag: tar.TypeReg, Mode: 0o644, Size: 0, Format: tar.FormatPAX,
191+ PAXRecords: map[string]string{"comment": strings.Repeat("x", 4096)}}
192+ if err := tw.WriteHeader(h); err != nil {
193+ t.Fatal(err)
194+ }
195+ }
196+ _ = tw.Close()
197+ _ = zw.Close()
198+ err := extractTarball(context.Background(), bytes.NewReader(buf.Bytes()), t.TempDir(), nil)
199+ if err == nil || !strings.Contains(err.Error(), "too large once unpacked") {
200+ t.Fatalf("%v", err)
201+ }
202+}
203+
204+func TestExtractTarballCancelled(t *testing.T) {
205+ ctx, cancel := context.WithCancel(context.Background())
206+ cancel()
207+ err := extractTarball(ctx, tarball(t, tent{name: "t/a", body: "1"}), t.TempDir(), nil)
208+ if err != context.Canceled {
209+ t.Fatalf("%v", err)
210+ }
211+}
212+
213+// GO-007: where a link leads depends on the other links. "a -> ." makes
214+// "link -> a/../outside" leave the tree, which a lexical check accepts.
215+func TestExtractTarballLinkChainsAreResolved(t *testing.T) {
216+ sym := func(name, to string) tent { return tent{name: "top/" + name, typ: tar.TypeSymlink, link: to} }
217+ for name, tc := range map[string]struct {
218+ ents []tent
219+ ok bool
220+ }{
221+ "two links escape": {[]tent{sym("a", "."), sym("link", "a/../outside")}, false},
222+ "two links, reverse order": {[]tent{sym("link", "a/../outside"), sym("a", ".")}, false}, // forward reference
223+ "three hops": {[]tent{sym("a", "b"), sym("b", "."), sym("link", "a/../../x")}, false},
224+ "through a directory link": {[]tent{{name: "top/d/e/f", body: "x"}, sym("d/up", "../.."), sym("link", "d/up/..")}, false},
225+ "absolute inside a hop": {[]tent{sym("a", "/etc"), sym("link", "a/passwd")}, false},
226+ "loop": {[]tent{sym("a", "b"), sym("b", "a"), sym("link", "a/x")}, false},
227+ "long chain": {chain(45), false},
228+ "stays inside": {[]tent{{name: "top/d/f", body: "1"}, sym("a", "."), sym("link", "a/d/f"), sym("up", "d/..")}, true},
229+ "dangling is fine": {[]tent{sym("link", "nowhere/at/all")}, true},
230+ "forward ref inside": {[]tent{sym("link", "a/b"), sym("a", "d"), {name: "top/d/b", body: "1"}}, true},
231+ "dotdot after real dir": {[]tent{{name: "top/d/f", body: "1"}, sym("link", "d/../d/f")}, true},
232+ } {
233+ t.Run(name, func(t *testing.T) {
234+ parent := t.TempDir()
235+ dir := filepath.Join(parent, "stage")
236+ _ = os.Mkdir(dir, 0o755)
237+ err := extractTarball(context.Background(), tarball(t, tc.ents...), dir, nil)
238+ if (err == nil) != tc.ok {
239+ t.Fatalf("accepted=%v, want %v (%v)", err == nil, tc.ok, err)
240+ }
241+ })
242+ }
243+}
244+
245+// chain makes n links, each one to the next, the last to ".": more hops than
246+// the kernel follows.
247+func chain(n int) []tent {
248+ var out []tent
249+ for i := 0; i < n; i++ {
250+ to := "l" + string(rune('A'+(i+1)%26)) + string(rune('a'+(i+1)/26))
251+ if i == n-1 {
252+ to = "."
253+ }
254+ out = append(out, tent{name: "top/l" + string(rune('A'+i%26)) + string(rune('a'+i/26)), typ: tar.TypeSymlink, link: to})
255+ }
256+ return append(out, tent{name: "top/start", typ: tar.TypeSymlink, link: "lAa/x"})
257+}
A internal/executor/actions_inputs.go +139 -0
@@ -0,0 +1,139 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strings"
9+
10+ "bnema.dev/ermite-actions/action"
11+ "bnema.dev/ermite-actions/expr"
12+ "bnema.dev/ermite-actions/template"
13+ "bnema.dev/ermite-actions/workflow"
14+)
15+
16+// inputSet is the inputs of a step: a dictionary with OrdinalIgnoreCase keys
17+// that keeps insertion order (Dictionary<string,string>, which only keeps it
18+// while nothing is removed).
19+type inputSet struct {
20+ keys []string
21+ vals map[string]string
22+}
23+
24+func newInputSet() *inputSet { return &inputSet{vals: map[string]string{}} }
25+
26+func (in *inputSet) lower(k string) string { return strings.ToLower(k) }
27+
28+func (in *inputSet) Set(k, v string) {
29+ l := in.lower(k)
30+ if _, ok := in.vals[l]; !ok {
31+ in.keys = append(in.keys, k)
32+ }
33+ in.vals[l] = v
34+}
35+
36+func (in *inputSet) Get(k string) (string, bool) { v, ok := in.vals[in.lower(k)]; return v, ok }
37+func (in *inputSet) Has(k string) bool { _, ok := in.vals[in.lower(k)]; return ok }
38+func (in *inputSet) Len() int { return len(in.keys) }
39+
40+// evalInputs is EvaluateStepInputs: the "with" of the step (a mapping, or the
41+// expression of a composite action's step that stands for one).
42+func (j *job) evalInputs(st *workflow.Step, env *ordered) (*inputSet, error) {
43+ in := newInputSet()
44+ var tok template.Token
45+ switch {
46+ case st.With != nil:
47+ tok = st.With
48+ case st.WithExpression != nil:
49+ tok = st.WithExpression
50+ default:
51+ return in, nil
52+ }
53+ v, err := template.Evaluate(context.Background(), tok, j.evalEnv(env))
54+ if err != nil {
55+ return nil, err
56+ }
57+ d, ok := v.(*expr.Dictionary)
58+ if !ok {
59+ return in, nil
60+ }
61+ for _, p := range d.Pairs() {
62+ if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary {
63+ return nil, ghErrorf("Unexpected type '%s' encountered while reading 'inputs value'. The type 'StringToken' was expected.", k)
64+ }
65+ in.Set(p.Key, expr.CoerceString(p.Value))
66+ }
67+ return in, nil
68+}
69+
70+// mergeInputs is the second half of ActionRunner.RunAsync's input handling
71+// (ActionRunner.cs:121-186): deprecation warnings for the inputs the step
72+// gives, the defaults of the others (evaluated in the context of the step), and
73+// the warning about inputs the action does not declare. The missing required
74+// inputs are not checked: the runner has no such check.
75+func (j *job) mergeInputs(s *stepRun, def *actionDef, in *inputSet) error {
76+ var act *action.Action
77+ if def != nil {
78+ act = def.act
79+ }
80+ user := append([]string(nil), in.keys...)
81+ if act != nil {
82+ for _, k := range user {
83+ for i := range act.Inputs {
84+ if act.Inputs[i].Name == k && act.Inputs[i].Deprecated() {
85+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Input '%s' has been deprecated with message: %s", k, act.Inputs[i].DeprecationMessage))
86+ }
87+ }
88+ }
89+ }
90+ valid := newInputSet()
91+ if def != nil && def.kind == kindDocker {
92+ // A container action always accepts 'entryPoint' and 'args'.
93+ valid.Set("entryPoint", "")
94+ valid.Set("args", "")
95+ }
96+ if act != nil {
97+ for i := range act.Inputs {
98+ name := act.Inputs[i].Name
99+ valid.Set(name, "")
100+ if in.Has(name) {
101+ continue
102+ }
103+ val, err := j.evalDefault(act.Inputs[i].Default, s.env)
104+ if err != nil {
105+ return err
106+ }
107+ in.Set(name, val)
108+ }
109+ }
110+ // Only for actions with a manifest or a repository reference (not docker://).
111+ if s.n.uses.Kind == action.UsesDocker {
112+ return nil
113+ }
114+ var unexpected []string
115+ for _, k := range user {
116+ if !valid.Has(k) {
117+ unexpected = append(unexpected, k)
118+ }
119+ }
120+ if len(unexpected) > 0 {
121+ j.tagged(s.num, "##[warning]", fmt.Sprintf("Unexpected input(s) '%s', valid inputs are ['%s']",
122+ strings.Join(unexpected, "', '"), strings.Join(valid.keys, "', '")))
123+ }
124+ return nil
125+}
126+
127+// evalDefault is EvaluateDefaultInput: the default is a string or an expression
128+// over github, strategy, matrix, job, runner and hashFiles.
129+func (j *job) evalDefault(tok template.Token, env *ordered) (string, error) {
130+ if tok == nil {
131+ return "", nil
132+ }
133+ return j.evalString(tok, env)
134+}
135+
136+// inputEnvName is the variable of an input (Handler.AddInputsToEnvironment).
137+func inputEnvName(key string) string {
138+ return "INPUT_" + strings.ToUpper(strings.ReplaceAll(key, " ", "_"))
139+}
A internal/executor/actions_job.go +46 -0
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import "os"
6+
7+// prepareJob is the actions part of "Set up job" (ActionManager.PrepareActionsAsync
8+// and JobExtension.cs:299-362): resolve and download the actions of the job,
9+// and build the queue of the steps: the steps that pull and build images, the
10+// pre stages in the order of the steps, then the steps themselves.
11+func (j *job) prepareJob() ([]qstep, error) {
12+ j.actions = newActionState()
13+ // The actions of a previous job are not reused (ActionManager.cs:97-100).
14+ _ = os.RemoveAll(j.state.Layout().Actions)
15+ if err := os.MkdirAll(j.state.Layout().Actions, 0o755); err != nil {
16+ return nil, err
17+ }
18+ j.own(j.state.Layout().Actions)
19+ nodes, err := newNodes(j.jb.Steps, nil, "")
20+ if err != nil {
21+ return nil, err
22+ }
23+ j.actions.nodes = nodes
24+ if err := j.prepare(j.ctx(), 1, nodes, 0, nil); err != nil {
25+ return nil, j.setupError(err)
26+ }
27+ queue := j.setupSteps()
28+ for _, n := range nodes {
29+ if j.actions.pre[n] {
30+ queue = append(queue, qstep{n: n, stage: stagePre, cond: n.def.preIf()})
31+ }
32+ }
33+ // The pre steps come before every main step: they were collected while
34+ // walking the steps, the mains follow.
35+ for _, n := range nodes {
36+ queue = append(queue, qstep{n: n, stage: stageMain})
37+ }
38+ return queue, nil
39+}
40+
41+// setupError words an error of the preparation like the runner reports it:
42+// errors of the manifests were logged already; "Failed to resolve action
43+// download info." and "Failed to download archive ..." carry the inner message.
44+func (j *job) setupError(err error) error {
45+ return err
46+}
A internal/executor/actions_load.go +123 -0
@@ -0,0 +1,123 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+
11+ "bnema.dev/ermite-actions/action"
12+)
13+
14+// loadManifest reads action.yml or action.yaml (the first is preferred) from
15+// dir, like ActionManager.LoadAction (ActionManager.cs:722-731) and
16+// ActionManifestManagerLegacy.Load (:53-160): every error of the manifest is
17+// logged as an error of the step, then the load fails with "Failed to load
18+// <path>". shown is the path the messages use: relative to the actions
19+// directory for downloaded actions, the whole path for local ones
20+// (ActionManifestManagerLegacy.cs:61-66). It returns (nil, "", nil) without a
21+// manifest.
22+func (j *job) loadManifest(num int, dir string) (*action.Action, string, error) {
23+ var file string
24+ for _, name := range []string{"action.yml", "action.yaml"} {
25+ if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
26+ file = filepath.Join(dir, name)
27+ break
28+ }
29+ }
30+ if file == "" {
31+ return nil, "", nil
32+ }
33+ shown := file
34+ if base := j.state.Layout().Actions; strings.HasPrefix(file, base+string(filepath.Separator)) {
35+ shown = file[len(base)+1:]
36+ }
37+ data, err := os.ReadFile(file)
38+ if err != nil {
39+ return nil, "", err
40+ }
41+ act, errs := action.Parse(shown, data)
42+ if len(errs) > 0 {
43+ for _, e := range errs {
44+ j.errorf(num, "%s", e.Error())
45+ }
46+ return nil, "", ghErrorf("%s", action.LoadFailed(shown))
47+ }
48+ return act, shown, nil
49+}
50+
51+func kindOfRuns(r *action.Runs) kind {
52+ switch r.Kind {
53+ case action.RunsNode:
54+ return kindNode
55+ case action.RunsDocker:
56+ return kindDocker
57+ case action.RunsComposite:
58+ return kindComposite
59+ }
60+ return kindScript
61+}
62+
63+// loadDef loads the action in dir (ActionManager.LoadAction): its manifest, or
64+// a Dockerfile when there is none (Dockerfile before dockerfile). what names
65+// the action in the error for a directory without any.
66+func (j *job) loadDef(num int, dir string) (*actionDef, error) {
67+ act, _, err := j.loadManifest(num, dir)
68+ if err != nil {
69+ return nil, err
70+ }
71+ if act != nil {
72+ d := &actionDef{kind: kindOfRuns(act.Runs), dir: dir, act: act}
73+ switch act.Runs.Kind {
74+ case action.RunsDocker:
75+ d.image = act.Runs.Image
76+ case action.RunsPlugin:
77+ return nil, ghErrorf("Unsupported action type: Plugin")
78+ }
79+ return d, nil
80+ }
81+ for _, name := range []string{"Dockerfile", "dockerfile"} {
82+ if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
83+ return &actionDef{kind: kindDocker, dir: dir, dockerfile: filepath.Join(dir, name)}, nil
84+ }
85+ }
86+ return nil, errNoAction
87+}
88+
89+// errNoAction is the sentinel of a directory without manifest and Dockerfile.
90+var errNoAction = fmt.Errorf("no action")
91+
92+// actionDir is where the action of a node lives: the workspace for a local
93+// action, the download directory for a repository one (ActionManager.cs:665-694).
94+func (j *job) actionDir(n *node) string {
95+ switch n.uses.Kind {
96+ case action.UsesLocal:
97+ return filepath.Join(j.state.GitHub("workspace"), filepath.FromSlash(strings.ReplaceAll(n.uses.Path, `\`, "/")))
98+ case action.UsesRepository:
99+ d := filepath.Join(j.state.Layout().Actions, filepath.FromSlash(n.uses.Name), n.uses.Ref)
100+ if n.uses.Path != "" {
101+ d = filepath.Join(d, filepath.FromSlash(n.uses.Path))
102+ }
103+ return d
104+ }
105+ return ""
106+}
107+
108+// refString is "Name[/Path]@Ref" (ActionRunner.cs:119-121, Handler.cs:130-148).
109+func refString(u action.Uses) string {
110+ switch u.Kind {
111+ case action.UsesRepository:
112+ p := u.Name
113+ if u.Path != "" {
114+ p += "/" + u.Path
115+ }
116+ return p + "@" + u.Ref
117+ case action.UsesLocal:
118+ return u.Path
119+ case action.UsesDocker:
120+ return "docker://" + u.Image
121+ }
122+ return ""
123+}
A internal/executor/actions_node.go +184 -0
@@ -0,0 +1,184 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+
11+ "bnema.dev/ermite-actions/action"
12+)
13+
14+const (
15+ node20Name = "node20"
16+ node24Name = "node24"
17+ // Node20DeprecationURL is Constants.Runner.NodeMigration.Node20DeprecationUrl.
18+ node20DeprecationURL = "https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/"
19+ forceNode24Var = "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24"
20+ allowUnsecureVar = "ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION"
21+)
22+
23+// convertToBool is StringUtil.ConvertToBoolean with a false default.
24+func convertToBool(v string) bool {
25+ switch strings.ToLower(v) {
26+ case "1", "true", "$true":
27+ return true
28+ }
29+ return false
30+}
31+
32+// envFlag is NodeUtil.GetEnvironmentVariableDetails: the value comes from the
33+// workflow's environment (the env context of the step) when it is set there,
34+// from the process of the runner otherwise.
35+type envFlag struct{ on, fromWorkflow, fromSystem bool }
36+
37+func (j *job) nodeEnvFlag(name string, wf *ordered) envFlag {
38+ var f envFlag
39+ v, found := wf.Get(name)
40+ if found {
41+ f.fromWorkflow = true
42+ f.on = convertToBool(v)
43+ }
44+ sys := envValue(j.baseEnv, name)
45+ f.fromSystem = sys != ""
46+ if !found {
47+ f.on = convertToBool(sys)
48+ }
49+ return f
50+}
51+
52+// nodeVersionDefault is NodeUtil.DetermineActionsNodeVersion
53+// (NodeUtil.cs:50-110) for an action that says node20: the version the action
54+// runs on and the warning of a conflicting configuration. useNode24ByDefault is
55+// the server's flag, ActionsConfig.Node24ByDefault here.
56+func (j *job) nodeVersionDefault(wf *ordered) (version, warning string) {
57+ force := j.nodeEnvFlag(forceNode24Var, wf)
58+ allow := j.nodeEnvFlag(allowUnsecureVar, wf)
59+ byDefault := j.cfg.Actions.Node24ByDefault
60+ bothWorkflow := force.on && allow.on && force.fromWorkflow && allow.fromWorkflow
61+ bothSystem := force.on && allow.on && force.fromSystem && allow.fromSystem
62+ if bothWorkflow || bothSystem {
63+ source := "system"
64+ if bothWorkflow {
65+ source = "workflow"
66+ }
67+ def := node20Name
68+ if byDefault {
69+ def = node24Name
70+ }
71+ return def, fmt.Sprintf("Both %s and %s environment variables are set to true in the %s environment. This is likely a configuration error. Using the default Node version: %s.",
72+ forceNode24Var, allowUnsecureVar, source, def)
73+ }
74+ if byDefault {
75+ if allow.on {
76+ return node20Name, ""
77+ }
78+ return node24Name, ""
79+ }
80+ if force.on {
81+ return node24Name, ""
82+ }
83+ return node20Name, ""
84+}
85+
86+// nodeVersionFor is the node part of HandlerFactory.Create
87+// (HandlerFactory.cs:56-170): node12 and node16 run on node20, node20 on
88+// whatever DetermineActionsNodeVersion says, node24 on node24. The result is
89+// kept in the step for the handler.
90+func (j *job) nodeVersionFor(s *stepRun, def *actionDef) error {
91+ r := def.runs()
92+ if r == nil || def.kind != kindNode {
93+ return nil
94+ }
95+ v := r.Using
96+ if v == "node12" || v == "node16" {
97+ v = node20Name
98+ }
99+ if v == node20Name {
100+ final, warn := j.nodeVersionDefault(s.env)
101+ v = final
102+ if warn != "" {
103+ j.tagged(s.num, "##[warning]", warn)
104+ }
105+ if final == node24Name {
106+ j.mu.Lock()
107+ j.upgradedToNode24 = appendUnique(j.upgradedToNode24, actionName(s.n))
108+ j.mu.Unlock()
109+ if j.cfg.Actions.Node24ByDefault {
110+ j.out(s.num, "Node 20 is being deprecated. This workflow is running with Node 24 by default. "+
111+ "If you need to temporarily use Node 20, you can set the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable. "+
112+ "For more information see: "+node20DeprecationURL)
113+ }
114+ }
115+ }
116+ s.nodeVersion = v
117+ return nil
118+}
119+
120+func appendUnique(l []string, v string) []string {
121+ if v == "" {
122+ return l
123+ }
124+ for _, x := range l {
125+ if x == v {
126+ return l
127+ }
128+ }
129+ return append(l, v)
130+}
131+
132+// actionName is HandlerFactory.GetActionName: "Name[/Path]@Ref".
133+func actionName(n *node) string {
134+ if n.uses.Kind == action.UsesLocal {
135+ return n.uses.Path // no name: HandlerFactory.cs:200-208 uses the path alone
136+ }
137+ return registeredRef(n.uses)
138+}
139+
140+// externalsDir is where the node versions live.
141+func (j *job) externalsDir() string {
142+ if d := j.cfg.Actions.ExternalsDir; d != "" {
143+ return d
144+ }
145+ return defaultExternalsDir
146+}
147+
148+// runNode is NodeScriptActionHandler.RunAsync (NodeScriptActionHandler.cs:
149+// 27-196) on the host of the steps (a job container is phase H): INPUT_
150+// variables and the PATH of the previous steps in the environment, the
151+// script of the stage, node from the externals, run in the workspace.
152+func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, cancel <-chan struct{}) stepResult {
153+ num := s.num
154+ for _, k := range in.keys {
155+ v, _ := in.Get(k)
156+ env.Set(inputEnvName(k), v)
157+ }
158+ // The PATH of the previous steps is added by procEnv.
159+ env.Remove("NODE_ICU_DATA") // https://github.com/actions/runner/issues/795
160+ r := def.runs()
161+ var target string
162+ switch s.stage {
163+ case stageMain:
164+ target = r.Main
165+ case stagePre:
166+ target = r.Pre
167+ case stagePost:
168+ target = r.Post
169+ }
170+ fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
171+ if target == "" {
172+ return fail(ghErrorf("Value cannot be null or empty. (Parameter 'target')"))
173+ }
174+ script := filepath.Join(def.dir, filepath.FromSlash(target))
175+ if st, err := os.Stat(script); err != nil || st.IsDir() {
176+ return fail(ghErrorf("File not found: '%s'", script))
177+ }
178+ j.mu.Lock()
179+ wd := j.state.GitHub("workspace")
180+ j.mu.Unlock()
181+ node := filepath.Join(j.externalsDir(), s.nodeVersion, "bin", "node")
182+ s.actEnv = env
183+ return j.runProc(s, node, wd, []string{node, script}, "Node Action run completed with exit code %d", cancel)
184+}
A internal/executor/actions_prepare.go +322 -0
@@ -0,0 +1,322 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "strings"
8+
9+ "bnema.dev/ermite-actions/action"
10+)
11+
12+// actionState is what the pre-job phase leaves for the run.
13+type actionState struct {
14+ nodes []*node
15+ // pulled and built map an image to what the setup steps produced.
16+ pulled map[string]string // image -> image
17+ built map[string]string // action repository -> tag
18+ // setup are the steps that pull and build images, in the order of
19+ // ActionManager.PrepareActionsAsync (pulls, then builds).
20+ pullOrder, buildOrder []string
21+ buildInfo map[string]buildInfo
22+ pullUsers map[string][]*node
23+ buildUsers map[string][]*node
24+ // pre are the pre steps of the job's steps with a pre stage, by node.
25+ pre map[*node]bool
26+ // resolved caches the download info by "name@ref" (ActionManager's legacy
27+ // path asks again at every level; ermite's server caches per run).
28+ downloaded map[string]bool
29+ // infos are the resolutions of the job by "Name@Ref": an action that a local
30+ // composite action brings again is not asked for again.
31+ infos map[string]ResolvedAction
32+}
33+
34+type buildInfo struct{ repository, dockerfile, workDir string }
35+
36+func newActionState() *actionState {
37+ return &actionState{
38+ pulled: map[string]string{}, built: map[string]string{}, buildInfo: map[string]buildInfo{},
39+ pullUsers: map[string][]*node{}, buildUsers: map[string][]*node{}, pre: map[*node]bool{},
40+ downloaded: map[string]bool{}, infos: map[string]ResolvedAction{},
41+ }
42+}
43+
44+// lookupKey is ActionManager.GetDownloadInfoLookupKey: "Name@Ref" for a
45+// repository action, "" for a local one.
46+func lookupKey(n *node) (string, error) {
47+ switch n.uses.Kind {
48+ case action.UsesRepository:
49+ return n.uses.Name + "@" + n.uses.Ref, nil
50+ case action.UsesSelfRepository:
51+ return "", ghErrorf("Unable to resolve self-reference '$/'. This can occur when the server does not support this syntax, the feature flag is disabled, or the workflow context (repository/SHA) is unavailable.")
52+ }
53+ return "", nil
54+}
55+
56+// prepare is ActionManager.PrepareActionsRecursiveLegacyAsync
57+// (ActionManager.cs:439-654): resolve and download the repository actions of
58+// nodes, read their manifests, recurse into composite actions, and note the
59+// images to pull or build and the pre and post stages. depth is 0 for the
60+// steps of the job and 1 for the ones of a local composite action prepared when
61+// it runs.
62+func (j *job) prepare(ctx context.Context, num int, nodes []*node, depth int, parent *node) error {
63+ if depth > compositeMaxDepth {
64+ return ghErrorf("Composite action depth exceeded max depth %d", compositeMaxDepth)
65+ }
66+ st := j.actions
67+ var repoNodes []*node
68+ for _, n := range nodes {
69+ switch n.uses.Kind {
70+ case action.UsesDocker:
71+ if n.uses.Image == "" {
72+ return ghErrorf("Value cannot be null or empty. (Parameter 'Image')")
73+ }
74+ if _, ok := st.pullUsers[n.uses.Image]; !ok {
75+ st.pullOrder = append(st.pullOrder, n.uses.Image)
76+ }
77+ st.pullUsers[n.uses.Image] = append(st.pullUsers[n.uses.Image], n)
78+ case action.UsesRepository, action.UsesLocal, action.UsesSelfRepository:
79+ repoNodes = append(repoNodes, n)
80+ }
81+ }
82+ if len(repoNodes) == 0 {
83+ return nil
84+ }
85+ infos, err := j.downloadInfo(ctx, num, repoNodes)
86+ if err != nil {
87+ return err
88+ }
89+ for _, n := range repoNodes {
90+ key, err := lookupKey(n)
91+ if err != nil {
92+ return err
93+ }
94+ if key == "" {
95+ continue
96+ }
97+ ra, ok := infos[key]
98+ if !ok {
99+ return ghErrorf("Missing download info for %s", key)
100+ }
101+ dir := j.actionDirOfRepo(n)
102+ // The directory of the archive is the one of the repository itself: the
103+ // action's path is below it.
104+ root := j.state.Layout().Actions + "/" + n.uses.Name + "/" + n.uses.Ref
105+ _ = dir
106+ if err := j.downloadAction(ctx, num, ra, ra.NameWithOwner, n.uses.Ref, root); err != nil {
107+ return err
108+ }
109+ }
110+ for _, n := range repoNodes {
111+ if err := j.prepareRepositoryAction(ctx, num, n, depth); err != nil {
112+ return err
113+ }
114+ }
115+ return nil
116+}
117+
118+func (j *job) actionDirOfRepo(n *node) string { return j.actionDir(n) }
119+
120+// downloadInfo is ActionManager.GetDownloadInfoAsync (ActionManager.cs:1000-1135):
121+// it asks the server, through the host, about the distinct repository actions
122+// of nodes. The server's answer for one action may be an error in GitHub's
123+// words, which is the message of the failure.
124+func (j *job) downloadInfo(ctx context.Context, num int, nodes []*node) (map[string]ResolvedAction, error) {
125+ j.out(num, "Getting action download info")
126+ var uses, keys []string
127+ seen := map[string]bool{}
128+ for _, n := range nodes {
129+ key, err := lookupKey(n)
130+ if err != nil {
131+ return nil, err
132+ }
133+ if key == "" || seen[key] {
134+ continue
135+ }
136+ seen[key] = true
137+ keys = append(keys, key)
138+ uses = append(uses, refString(n.uses))
139+ }
140+ out := map[string]ResolvedAction{}
141+ if len(uses) == 0 {
142+ return out, nil
143+ }
144+ // What the job resolved already is not asked again: the host would not call
145+ // the server for it either (host/resolve.go), but the round trip is saved.
146+ var askUses, askKeys []string
147+ for i, k := range keys {
148+ if ra, ok := j.actions.infos[k]; ok {
149+ out[k] = ra
150+ continue
151+ }
152+ askUses, askKeys = append(askUses, uses[i]), append(askKeys, k)
153+ }
154+ if len(askUses) == 0 {
155+ return out, nil
156+ }
157+ uses, keys = askUses, askKeys
158+ if j.cfg.Actions.Resolver == nil {
159+ return nil, ghErrorf("Failed to resolve action download info.")
160+ }
161+ res, err := j.cfg.Actions.Resolver(ctx, uses)
162+ if err != nil {
163+ return nil, ghErrorf("%s", err.Error())
164+ }
165+ if len(res) != len(uses) {
166+ return nil, ghErrorf("Failed to resolve action download info.")
167+ }
168+ for i, ra := range res {
169+ if ra.Error != "" {
170+ return nil, ghErrorf("%s", ra.Error)
171+ }
172+ // The token is a secret from here on (ActionManager.cs:1130).
173+ j.addTokenMask(ra.Token)
174+ if ra.NameWithOwner == "" {
175+ ra.NameWithOwner = strings.SplitN(keys[i], "@", 2)[0]
176+ }
177+ out[keys[i]] = ra
178+ j.actions.infos[keys[i]] = ra
179+ }
180+ return out, nil
181+}
182+
183+// prepareRepositoryAction is ActionManager.PrepareRepositoryActionAsync
184+// (:1411-1550) and the code after it in the loop of the caller: read the
185+// manifest, note an image to pull or build, recurse into a composite, and
186+// register pre and post stages.
187+func (j *job) prepareRepositoryAction(ctx context.Context, num int, n *node, depth int) error {
188+ local := n.uses.Kind == action.UsesLocal
189+ if local {
190+ if depth > 0 {
191+ // "if we're in a composite action and haven't loaded the local action
192+ // yet we assume it has a post step" (ActionManager.cs:643-654).
193+ n.parentPost()
194+ }
195+ return nil
196+ }
197+ dir := j.actionDir(n)
198+ def, err := j.loadDef(num, dir)
199+ if err == errNoAction {
200+ return ghErrorf("Can't find 'action.yml', 'action.yaml' or 'Dockerfile' for action '%s'.", refString(n.uses))
201+ }
202+ if err != nil {
203+ return err
204+ }
205+ n.def = def
206+ repo := n.uses.Name + "@" + n.uses.Ref
207+ if n.uses.Path != "" {
208+ repo = n.uses.Name + "/" + n.uses.Path + "@" + n.uses.Ref
209+ }
210+ root := j.state.Layout().Actions + "/" + n.uses.Name + "/" + n.uses.Ref
211+ st := j.actions
212+ switch def.kind {
213+ case kindDocker:
214+ img, build, err := j.dockerImageOf(def)
215+ if err != nil {
216+ return err
217+ }
218+ if build != "" {
219+ if _, ok := st.buildUsers[repo]; !ok {
220+ st.buildOrder = append(st.buildOrder, repo)
221+ }
222+ st.buildUsers[repo] = append(st.buildUsers[repo], n)
223+ st.buildInfo[repo] = buildInfo{repository: repo, dockerfile: build, workDir: root}
224+ } else {
225+ if _, ok := st.pullUsers[img]; !ok {
226+ st.pullOrder = append(st.pullOrder, img)
227+ }
228+ st.pullUsers[img] = append(st.pullUsers[img], n)
229+ }
230+ case kindComposite:
231+ if err := j.prepareComposite(ctx, num, n, depth); err != nil {
232+ return err
233+ }
234+ }
235+ j.registerStages(n, depth)
236+ return nil
237+}
238+
239+// dockerImageOf says what a container action needs: an image to pull, or a
240+// Dockerfile to build (absolute path).
241+func (j *job) dockerImageOf(def *actionDef) (pull, build string, err error) {
242+ if def.act == nil {
243+ return "", def.dockerfile, nil
244+ }
245+ im, err := action.ParseImage(def.image)
246+ if err != nil {
247+ return "", "", err
248+ }
249+ if im.Kind == action.ImageDockerfile {
250+ return "", def.dir + "/" + im.Path, nil
251+ }
252+ return im.Ref, "", nil
253+}
254+
255+// prepareComposite creates the nodes of a composite action's steps and prepares
256+// them one level down.
257+func (j *job) prepareComposite(ctx context.Context, num int, n *node, depth int) error {
258+ steps := n.def.act.Runs.Steps
259+ kids, err := newNodes(steps, n, n.scope)
260+ if err != nil {
261+ return err
262+ }
263+ n.children = kids
264+ return j.prepare(ctx, num, kids, depth+1, n)
265+}
266+
267+// registerStages notes the pre and post stages of a prepared action that is not
268+// local (ActionManager.cs:600-640): for the steps of the job a pre step, for
269+// the steps of a composite action a pre and post step of the composite.
270+func (j *job) registerStages(n *node, depth int) {
271+ pre, post := n.hasPre(), n.hasPost()
272+ if depth < 1 {
273+ if pre {
274+ j.actions.pre[n] = true
275+ }
276+ return
277+ }
278+ if p := n.parent; p != nil {
279+ if pre {
280+ p.preSteps = append(p.preSteps, n)
281+ }
282+ if post {
283+ p.postSteps = append(p.postSteps, n)
284+ }
285+ }
286+}
287+
288+// parentPost is the assumption for a local action inside a composite action.
289+func (n *node) parentPost() {
290+ if p := n.parent; p != nil {
291+ p.postSteps = append(p.postSteps, n)
292+ }
293+}
294+
295+// hasPre and hasPost are ActionExecutionData.HasPre and HasPost.
296+func (n *node) hasPre() bool {
297+ d := n.def
298+ if d == nil {
299+ return false
300+ }
301+ if d.kind == kindComposite {
302+ return len(n.preSteps) > 0
303+ }
304+ if r := d.runs(); r != nil {
305+ return r.HasPre()
306+ }
307+ return false
308+}
309+
310+func (n *node) hasPost() bool {
311+ d := n.def
312+ if d == nil {
313+ return false
314+ }
315+ if d.kind == kindComposite {
316+ return len(n.postSteps) > 0
317+ }
318+ if r := d.runs(); r != nil {
319+ return r.HasPost()
320+ }
321+ return false
322+}
A internal/executor/actions_results_test.go +298 -0
@@ -0,0 +1,298 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "encoding/base64"
7+ "fmt"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+// actionResults runs a workflow of actions with a recorder that keeps the
14+// results of the steps (annotations, summaries, outcomes).
15+func actionResults(t *testing.T, tree files, yaml string, tweak func(*Config)) (*resultRecorder, Result) {
16+ t.Helper()
17+ needTools(t, "bash")
18+ rr := newResultRecorder()
19+ _, res := runLocal(t, wfHead+" steps:\n"+yaml, "j", func(c *Config) {
20+ writeFiles(t, c.Layout.Workspace, tree)
21+ c.Actions.ExternalsDir = fakeNode(t)
22+ c.Reporter = rr
23+ c.SendAnnotations, c.SendSummaries = true, true
24+ if tweak != nil {
25+ tweak(c)
26+ }
27+ })
28+ return rr, res
29+}
30+
31+func countLevel(as []Annotation, l AnnotationLevel) int {
32+ n := 0
33+ for _, a := range as {
34+ if a.Level == l {
35+ n++
36+ }
37+ }
38+ return n
39+}
40+
41+// Commands from a node action: annotations, masks, a problem matcher, the step
42+// summary and the saved state work as in a run step.
43+func TestNodeActionCommands(t *testing.T) {
44+ tree := files{
45+ "n/action.yml": "name: n\nruns:\n using: node20\n main: main.sh\n",
46+ "n/main.sh": `echo "::error title=T,file=a.txt,line=3::broken"
47+echo "::add-mask::hunter2"
48+echo "the password is hunter2"
49+echo "::warning::careful"
50+echo "# Summary hunter2" >> "$GITHUB_STEP_SUMMARY"
51+echo '{"problemMatcher":[{"owner":"m","pattern":[{"regexp":"^bad: (.+)$","message":1}]}]}' > "$RUNNER_TEMP/m.json"
52+echo "::add-matcher::$RUNNER_TEMP/m.json"
53+echo "bad: from the matcher"
54+`,
55+ }
56+ rr, res := actionResults(t, tree, " - uses: ./n\n", nil)
57+ if res.Conclusion != ConclusionSuccess {
58+ t.Fatalf("%+v\n%s", res, logOf(rr.steps[1]))
59+ }
60+ log := logOf(rr.step(t, "Run ./n"))
61+ if strings.Contains(log, "hunter2") || !strings.Contains(log, "the password is ***") {
62+ t.Errorf("add-mask:\n%s", log)
63+ }
64+ as := rr.results[2].Annotations
65+ if len(as) != 3 || as[0].Level != LevelFailure || as[0].Title != "T" || as[0].File != "a.txt" || as[0].StartLine != 3 ||
66+ as[1].Level != LevelWarning || as[2].Level != LevelFailure || as[2].Message != "from the matcher" {
67+ t.Errorf("annotations %+v", as)
68+ }
69+ if got := rr.results[2].Summary; got != "# Summary ***\n" {
70+ t.Errorf("summary %q", got)
71+ }
72+ // An ::error:: fails the step only through its exit code, not by itself, but
73+ // the command result merges: the step succeeds here, as the script exited 0.
74+ if rr.step(t, "Run ./n").Conclusion != ConclusionSuccess {
75+ t.Errorf("conclusion %s", rr.step(t, "Run ./n").Conclusion)
76+ }
77+}
78+
79+// The state of an action is kept under one key for its pre, main and post stages;
80+// the first spelling of a name wins and entries come in the order they were saved.
81+// (The runner never runs pre of a local action, so this one is a repository action.)
82+func TestActionStateKeepsFirstSpellingAndOrder(t *testing.T) {
83+ forge := newForge(t, map[string][]byte{"/tarball/o/p/v1": tarOf(t, "o-p-x", files{
84+ "action.yml": "name: p\nruns:\n using: node20\n pre: pre.sh\n main: main.sh\n post: post.sh\n",
85+ "pre.sh": "echo \"Zed=1\" >> \"$GITHUB_STATE\"\necho \"alpha=2\" >> \"$GITHUB_STATE\"\n",
86+ "main.sh": "echo \"ZED=3\" >> \"$GITHUB_STATE\"\necho \"main: $(env | grep '^STATE_' | sort | tr '\\n' ' ')\"\n",
87+ "post.sh": "echo \"post: $(env | grep '^STATE_' | sort | tr '\\n' ' ')\"\n",
88+ })})
89+ rr, res := actionResults(t, files{}, " - uses: o/p@v1\n", func(c *Config) { c.Actions.Resolver = forge.resolver("") })
90+ if res.Conclusion != ConclusionSuccess {
91+ t.Fatalf("%+v", res)
92+ }
93+ if got := logOf(rr.step(t, "Run o/p@v1")); !strings.Contains(got, "main: STATE_Zed=1 STATE_alpha=2 ") {
94+ t.Errorf("main:\n%s", got)
95+ }
96+ if got := logOf(rr.step(t, "Post Run o/p@v1")); !strings.Contains(got, "post: STATE_Zed=3 STATE_alpha=2 ") {
97+ t.Errorf("post:\n%s", got)
98+ }
99+}
100+
101+// Two uses of one action have two states, and the ::save-state:: command and the
102+// file end in the same one.
103+func TestActionStateIsPerStep(t *testing.T) {
104+ forge := newForge(t, map[string][]byte{"/tarball/o/q/v1": tarOf(t, "o-q-x", files{
105+ "action.yml": "name: q\ninputs:\n v:\n default: d\nruns:\n using: node20\n main: main.sh\n post: post.sh\n",
106+ "main.sh": "echo \"::save-state name=v::$INPUT_V\"\necho \"f=$INPUT_V\" >> \"$GITHUB_STATE\"\n",
107+ "post.sh": "echo \"post sees v=$STATE_v f=$STATE_f\"\n",
108+ })})
109+ rr, res := actionResults(t, files{}, " - uses: o/q@v1\n with:\n v: one\n - uses: o/q@v1\n with:\n v: two\n", func(c *Config) { c.Actions.Resolver = forge.resolver("") })
110+ if res.Conclusion != ConclusionSuccess {
111+ t.Fatalf("%+v", res)
112+ }
113+ var posts []string
114+ for _, s := range rr.steps {
115+ if strings.HasPrefix(s.Name, "Post ") {
116+ posts = append(posts, logOf(s))
117+ }
118+ }
119+ if len(posts) != 2 || !strings.Contains(posts[0], "post sees v=two f=two") || !strings.Contains(posts[1], "post sees v=one f=one") {
120+ t.Errorf("%q", posts)
121+ }
122+}
123+
124+// ::error:: of a node action and of the inner steps of a composite land on the
125+// step of the workflow; the limit of 10 per level counts every inner step.
126+func TestCompositeAnnotationsAreCountedOnTheOuterStep(t *testing.T) {
127+ inner := func(n int, level string) string {
128+ return " run: |\n for i in $(seq 1 " + fmt.Sprint(n) + "); do echo \"::" + level + "::" + level + " $i\"; done\n"
129+ }
130+ tree := files{
131+ "c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - shell: bash\n" + inner(8, "error") +
132+ " - shell: bash\n" + inner(8, "error") + " - shell: bash\n" + inner(3, "warning") + " - uses: ./n\n",
133+ "n/action.yml": "name: n\nruns:\n using: node20\n main: main.sh\n",
134+ "n/main.sh": "echo '::notice::from the node action inside'\n",
135+ }
136+ rr, res := actionResults(t, tree, " - uses: ./c\n - run: echo \"::error::the next step\"\n", nil)
137+ if res.Conclusion != ConclusionSuccess {
138+ t.Fatalf("%+v", res)
139+ }
140+ as := rr.results[2].Annotations
141+ if countLevel(as, LevelFailure) != 10 || countLevel(as, LevelWarning) != 3 || countLevel(as, LevelNotice) != 1 {
142+ t.Errorf("failure %d (want 10 of 16), warning %d, notice %d: %+v", countLevel(as, LevelFailure), countLevel(as, LevelWarning), countLevel(as, LevelNotice), as)
143+ }
144+ if as[0].Message != "error 1" {
145+ t.Errorf("the first ones are kept: %+v", as[0])
146+ }
147+ // Nothing leaks to the step after, and the inner steps have no number of their own.
148+ if got := rr.results[3].Annotations; len(got) != 1 || got[0].Message != "the next step" {
149+ t.Errorf("next step %+v", got)
150+ }
151+ // A local action inside a composite is assumed to have a post step, so the
152+ // composite has one too (ActionManager.cs:643-654): "Post Run ./c" is empty.
153+ if got := strings.Join(rr.names(), "|"); got != "Set up job|Run ./c|Run echo \"::error::the next step\"|Post Run ./c|Complete job" {
154+ t.Errorf("steps %v", rr.names())
155+ }
156+}
157+
158+// The outer step's own issues come first: a composite whose inner steps have used
159+// the 10 slots of "failure" cannot push out the error the runner logs for the
160+// outer step itself... in the runner's order, which is chronological.
161+func TestCompositeSummaryAndMaskBelongToTheOuterStep(t *testing.T) {
162+ tree := files{"c/action.yml": `name: c
163+runs:
164+ using: composite
165+ steps:
166+ - shell: bash
167+ run: |
168+ echo "::add-mask::inner-secret"
169+ echo "first" >> "$GITHUB_STEP_SUMMARY"
170+ - shell: bash
171+ run: |
172+ echo "second inner-secret" >> "$GITHUB_STEP_SUMMARY"
173+ echo "visible: inner-secret"
174+`}
175+ rr, res := actionResults(t, tree, " - uses: ./c\n - run: echo \"after - inner-secret\"\n", nil)
176+ if res.Conclusion != ConclusionSuccess {
177+ t.Fatalf("%+v", res)
178+ }
179+ if got := rr.results[2].Summary; got != "first\nsecond ***\n" {
180+ t.Errorf("summary %q", got)
181+ }
182+ if got := logOf(rr.steps[1]) + logOf(rr.steps[2]); strings.Contains(got, "inner-secret\n") && !strings.Contains(got, "***") || strings.Contains(strings.ReplaceAll(got, "::add-mask::inner-secret", ""), "visible: inner-secret") || strings.Contains(logOf(rr.steps[2]), "after - inner-secret") {
183+ t.Errorf("a mask added by an inner step must hide the value everywhere:\n%s", got)
184+ }
185+}
186+
187+// The archive token, and its Basic form, are masked wherever they appear.
188+func TestArchiveTokenAndBasicFormAreMasked(t *testing.T) {
189+ const token = "archive-tok-123"
190+ basic := base64.StdEncoding.EncodeToString([]byte("x-access-token:" + token))
191+ forge := newForge(t, map[string][]byte{"/tarball/o/t/v1": tarOf(t, "o-t-x", files{
192+ "action.yml": "name: t\nruns:\n using: node20\n main: main.sh\n",
193+ "main.sh": "echo \"token " + token + " basic " + basic + "\"\n"})})
194+ rr, res := actionResults(t, files{}, " - uses: o/t@v1\n", func(c *Config) { c.Actions.Resolver = forge.resolver(token) })
195+ if res.Conclusion != ConclusionSuccess {
196+ t.Fatalf("%+v", res)
197+ }
198+ if got := logOf(rr.step(t, "Run o/t@v1")); !strings.Contains(got, "token *** basic ***") {
199+ t.Errorf("%s", got)
200+ }
201+}
202+
203+// timeout-minutes on an action step, on a docker step and on an inner step of a
204+// composite: the step fails with the runner's message and the job goes on.
205+func TestActionStepTimeouts(t *testing.T) {
206+ skew := -(60*time.Second - 1500*time.Millisecond)
207+ tree := files{
208+ "slow/action.yml": "name: slow\nruns:\n using: node20\n main: main.sh\n",
209+ "slow/main.sh": "sleep 30\n",
210+ "c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - name: inner\n shell: bash\n run: sleep 30\n - shell: bash\n if: always()\n run: echo inner-after\n",
211+ }
212+ start := time.Now()
213+ rr, res := actionResults(t, tree, ` - name: node
214+ timeout-minutes: 1
215+ uses: ./slow
216+ - name: after
217+ if: always()
218+ run: echo after-ran
219+`, func(c *Config) { c.StepTimeoutSkew = skew })
220+ if res.Conclusion != ConclusionFailure {
221+ t.Fatalf("%+v", res)
222+ }
223+ if time.Since(start) > 10*time.Second {
224+ t.Errorf("the timeout did not stop the action: %v", time.Since(start))
225+ }
226+ n := rr.step(t, "node")
227+ if n.Conclusion != ConclusionFailure || !strings.Contains(logOf(n), "##[error]The action 'node' has timed out after 1 minutes.") || strings.Contains(logOf(n), "The operation was canceled.") {
228+ t.Errorf("%s\n%s", n.Conclusion, logOf(n))
229+ }
230+ if rr.step(t, "after").Conclusion != ConclusionSuccess {
231+ t.Error("the job is not cancelled by a step timeout")
232+ }
233+ // The inner step of a composite action.
234+ start = time.Now()
235+ rr, res = actionResults(t, tree, " - name: comp\n timeout-minutes: 1\n uses: ./c\n", func(c *Config) { c.StepTimeoutSkew = skew })
236+ if res.Conclusion != ConclusionFailure || time.Since(start) > 10*time.Second {
237+ t.Fatalf("%+v %v", res, time.Since(start))
238+ }
239+ log := logOf(rr.step(t, "comp"))
240+ // The inner step has what is left of the timeout of the step that uses the
241+ // action and fails with a message that names no step (the runner's composite
242+ // handler). The time is spent after it: the steps that follow are cancelled at
243+ // once, as the runner's are (their token is cancelled when they are created).
244+ if strings.Count(log, "##[error]The action has timed out.") < 1 || strings.Contains(log, "after 0 minutes") || strings.Contains(log, "The operation was canceled.") {
245+ t.Errorf("%s", log)
246+ }
247+}
248+
249+// A docker action's output goes through the same commands: annotations, masks,
250+// matchers.
251+func TestDockerActionCommands(t *testing.T) {
252+ dir := t.TempDir()
253+ script := `#!/bin/sh
254+case "$1" in
255+run)
256+ echo "::add-mask::dkr-secret"
257+ echo "value dkr-secret"
258+ echo "::error file=x.go,line=2::from docker"
259+ echo "::group::g"
260+ echo "::endgroup::"
261+ ;;
262+esac
263+exit 0
264+`
265+ writeFiles(t, dir, files{"docker": script})
266+ rr, res := actionResults(t, files{}, " - uses: docker://alpine:3\n", func(c *Config) { c.Actions.DockerPath = dir + "/docker" })
267+ if res.Conclusion != ConclusionSuccess {
268+ t.Fatalf("%+v", res)
269+ }
270+ log := logOf(rr.step(t, "Run alpine:3"))
271+ if strings.Contains(log, "value dkr-secret") || !strings.Contains(log, "value ***") {
272+ t.Errorf("%s", log)
273+ }
274+ if as := rr.results[3].Annotations; len(as) != 1 || as[0].Message != "from docker" || as[0].File != "x.go" || as[0].StartLine != 2 {
275+ t.Errorf("annotations %+v", rr.results)
276+ }
277+}
278+
279+// The outcome of an action step, before continue-on-error, reaches the reporter.
280+func TestActionOutcomeIsReported(t *testing.T) {
281+ tree := files{"f/action.yml": "name: f\nruns:\n using: node20\n main: main.sh\n", "f/main.sh": "exit 1\n"}
282+ rr, res := actionResults(t, tree, " - uses: ./f\n continue-on-error: true\n", nil)
283+ if res.Conclusion != ConclusionSuccess {
284+ t.Fatalf("%+v", res)
285+ }
286+ if got := rr.results[2].Outcome; got != ConclusionFailure {
287+ t.Errorf("outcome %q, want failure", got)
288+ }
289+ if rr.step(t, "Run ./f").Conclusion != ConclusionSuccess {
290+ t.Error("continue-on-error")
291+ }
292+ // A failing action says why in an annotation too: the exit code is an error of
293+ // the runner, which GitHub reports as an annotation for run steps; node
294+ // actions only mark the step failed.
295+ if as := rr.results[2].Annotations; len(as) != 0 {
296+ t.Errorf("annotations %+v", as)
297+ }
298+}
A internal/executor/actions_run.go +228 -0
@@ -0,0 +1,228 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "path/filepath"
8+ "strings"
9+
10+ "bnema.dev/ermite-actions/action"
11+)
12+
13+// stageName is for messages.
14+func (s stage) String() string {
15+ switch s {
16+ case stagePre:
17+ return "Pre"
18+ case stagePost:
19+ return "Post"
20+ }
21+ return "Main"
22+}
23+
24+// definition is ActionManager.LoadAction for the step: the manifest of a local
25+// action is read when the step runs; the other actions were loaded by the
26+// pre-job phase.
27+func (j *job) definition(s *stepRun) (*actionDef, error) {
28+ n := s.n
29+ switch n.uses.Kind {
30+ case action.UsesDocker:
31+ return &actionDef{kind: kindDocker, image: n.uses.Image}, nil
32+ case action.UsesLocal:
33+ dir := j.actionDir(n)
34+ def, err := j.loadDef(s.num, dir)
35+ if err == errNoAction {
36+ return nil, ghErrorf("Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under '%s'. Did you forget to run actions/checkout before running your local action?", filepath.Clean(dir))
37+ }
38+ if err != nil {
39+ return nil, err
40+ }
41+ n.def = def
42+ return def, nil
43+ }
44+ if n.def == nil {
45+ return nil, ghErrorf("The action '%s' was not prepared", refString(n.uses))
46+ }
47+ return n.def, nil
48+}
49+
50+// setActionRepository sets github.action_repository and action_ref
51+// (ActionRunner.cs:149-165): the repository and ref of a repository action, empty
52+// for anything else.
53+func (j *job) setActionRepository(n *node) {
54+ repo, ref := "", ""
55+ if n != nil && n.uses.Kind == action.UsesRepository {
56+ repo, ref = n.uses.Name, n.uses.Ref
57+ }
58+ j.mu.Lock()
59+ j.state.SetGitHub("action_repository", repo)
60+ j.state.SetGitHub("action_ref", ref)
61+ j.mu.Unlock()
62+}
63+
64+// registerPost registers the post stage of the action (ActionRunner.cs:99-126,
65+// ExecutionContext.RegisterPostJobStep): a step "Post <name>" on the stack the
66+// job runs after its steps, last registered first; inside a composite action
67+// only the fact is recorded, the composite's own post runs the step.
68+func (j *job) registerPost(s *stepRun, def *actionDef) {
69+ n := s.n
70+ name := s.name
71+ if s.stage == stagePre && strings.HasPrefix(strings.ToLower(name), "pre ") {
72+ name = name[len("Pre "):]
73+ }
74+ j.debug(s.num, "Register post job cleanup for action: "+registeredRef(n.uses))
75+ if s.parent != nil {
76+ n.postRegistered, n.postCond = true, def.postIf()
77+ return
78+ }
79+ if n.postRegistered {
80+ return // StepsWithPostRegistered
81+ }
82+ n.postRegistered = true
83+ j.mu.Lock()
84+ j.postStack = append(j.postStack, qstep{n: n, stage: stagePost, name: "Post " + name, cond: def.postIf()})
85+ j.mu.Unlock()
86+}
87+
88+// registeredRef is the text of the debug line of the registration.
89+func registeredRef(u action.Uses) string {
90+ path := ""
91+ if u.Path != "" {
92+ path = "/" + u.Path
93+ }
94+ if u.Ref == "" {
95+ return u.Name + path
96+ }
97+ return u.Name + path + "@" + u.Ref
98+}
99+
100+// handlerGroup is Handler.PrintActionDetails's group name.
101+func handlerGroup(u action.Uses) string {
102+ switch u.Kind {
103+ case action.UsesDocker:
104+ return "Run docker://" + u.Image
105+ case action.UsesLocal:
106+ return "Run " + u.Path
107+ }
108+ if u.Path == "" {
109+ return fmt.Sprintf("Run %s@%s", u.Name, u.Ref)
110+ }
111+ return fmt.Sprintf("Run %s/%s@%s", u.Name, u.Path, u.Ref)
112+}
113+
114+// printDetails is Handler.PrintActionDetails (Handler.cs:102-165): the group
115+// of the step with its inputs that are not empty and its environment. The post
116+// stage only prints "Post job cleanup.".
117+func (j *job) printDetails(s *stepRun, in *inputSet, env *ordered) {
118+ num := s.num
119+ if s.stage == stagePost {
120+ j.out(num, "Post job cleanup.")
121+ return
122+ }
123+ j.out(num, "##[group]"+handlerGroup(s.n.uses))
124+ if in.Len() > 0 {
125+ j.out(num, "with:")
126+ for _, k := range in.keys {
127+ if v, _ := in.Get(k); v != "" {
128+ j.out(num, fmt.Sprintf(" %s: %s", k, v))
129+ }
130+ }
131+ }
132+ if env.Len() > 0 {
133+ j.out(num, "env:")
134+ for _, k := range env.Keys() {
135+ v, _ := env.Get(k)
136+ j.out(num, fmt.Sprintf(" %s: %s", k, v))
137+ }
138+ }
139+ j.out(num, "##[endgroup]")
140+}
141+
142+// runAction is ActionRunner.RunAsync (ActionRunner.cs:62-247) for a uses step
143+// in any stage: load the action, evaluate the inputs and the environment, print
144+// the details and run the handler of the action.
145+func (j *job) runAction(s *stepRun, cancel <-chan struct{}) stepResult {
146+ num, n := s.num, s.n
147+ fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
148+ def, err := j.definition(s)
149+ if err != nil {
150+ return fail(err)
151+ }
152+ s.def = def
153+ local := n.uses.Kind == action.UsesLocal
154+ // A local composite action is expanded when it runs (ActionRunner.cs:77-96).
155+ if def.kind == kindComposite && s.stage == stageMain && local {
156+ if err := j.prepareLocalComposite(s, def, cancel); err != nil {
157+ if err == errSetupCancelled {
158+ return resCanceled // logged by the command that was stopped
159+ }
160+ // What the cancellation or the timeout of the step broke (a resolution,
161+ // a download, an extraction) is not a failure of the action: the step is
162+ // cancelled, and StepsRunner turns it into the timeout message when its
163+ // own timer fired. A genuine error keeps failing the step.
164+ if done(cancel) {
165+ s.logCanceled()
166+ return resCanceled
167+ }
168+ return fail(err)
169+ }
170+ }
171+ if local && n.hasPre() {
172+ j.tagged(num, "##[warning]", fmt.Sprintf("`pre` execution is not supported for local action from '%s'", n.uses.Path))
173+ }
174+ if n.hasPostOf(def) && s.stage != stagePost {
175+ j.registerPost(s, def)
176+ }
177+ j.setActionRepository(n)
178+ if err := j.prepareFileCommands(s); err != nil {
179+ return fail(err)
180+ }
181+ defer s.fc.Process(s)
182+
183+ j.debug(num, "Loading inputs")
184+ in, err := j.evalInputs(s.st, s.env)
185+ if err != nil {
186+ j.errorf(num, "%s", templateNotValid(err, true))
187+ return resFailed
188+ }
189+ if err := j.mergeInputs(s, def, in); err != nil {
190+ j.errorf(num, "%s", templateNotValid(err, true))
191+ return resFailed
192+ }
193+ j.debug(num, "Loading env")
194+ env := s.env.Clone()
195+ // The intra-action state, applied last (ActionRunner.cs:255-258): names as
196+ // first spelled, in the order they were saved.
197+ for _, p := range j.intraActionState(n.stateKey) {
198+ env.Set("STATE_"+p.Key, p.Value)
199+ }
200+ s.actEnv = env
201+ if err := j.nodeVersionFor(s, def); err != nil {
202+ return fail(err)
203+ }
204+ j.printDetails(s, in, env)
205+ switch def.kind {
206+ case kindNode:
207+ return j.runNode(s, def, in, env, cancel)
208+ case kindDocker:
209+ return j.runDocker(s, def, in, env, cancel)
210+ case kindComposite:
211+ return j.runComposite(s, def, in, env)
212+ }
213+ return fail(ghErrorf("Unsupported action type: %v", def.kind))
214+}
215+
216+// hasPostOf is HasPost of the loaded definition of the node.
217+func (n *node) hasPostOf(def *actionDef) bool {
218+ switch def.kind {
219+ case kindComposite:
220+ return len(n.postSteps) > 0
221+ case kindScript:
222+ return false
223+ }
224+ if r := def.runs(); r != nil {
225+ return r.HasPost()
226+ }
227+ return false
228+}
A internal/executor/actions_scope.go +83 -0
@@ -0,0 +1,83 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strings"
7+
8+ "bnema.dev/ermite-actions/expr"
9+)
10+
11+// stepsScope is a steps context: the one of the job, or the one of a composite
12+// action's steps (StepsContext.GetScope in the runner), plus the inputs the
13+// steps of that composite see.
14+type stepsScope struct {
15+ order []string
16+ steps map[string]*stepCtx
17+ inputs *expr.Dictionary // nil: the job's inputs context
18+}
19+
20+func newScope() *stepsScope { return &stepsScope{steps: map[string]*stepCtx{}} }
21+
22+// PushScope makes the steps context called name (created empty on first use)
23+// the current one, with inputs as the inputs context, and returns the function
24+// that restores the previous one. A composite action's pre, main and post
25+// stages share one scope (the same name), as the runner's do.
26+func (s *State) PushScope(name string, inputs *expr.Dictionary) (restore func()) {
27+ prev := s.cur
28+ sc, ok := s.scopes[name]
29+ if name == "" {
30+ sc, ok = s.root, true
31+ }
32+ if !ok {
33+ sc = newScope()
34+ s.scopes[name] = sc
35+ }
36+ if inputs != nil {
37+ sc.inputs = inputs
38+ }
39+ s.cur = sc
40+ return func() { s.cur = prev }
41+}
42+
43+// StepsValue returns the steps context in use (outputs of a composite action
44+// are evaluated against it).
45+func (s *State) StepsValue() *expr.Dictionary { return s.stepsValue() }
46+
47+// GitHubKeys saves the current value of the given keys of the github context,
48+// to put them back with the returned function: a key that was absent is
49+// removed again. Composite actions' steps get their own copy of the context in
50+// the runner (a shallow copy, CompositeActionHandler.cs:152-161); the
51+// executor keeps one and restores it.
52+func (s *State) GitHubKeys(keys ...string) (restore func()) {
53+ type saved struct {
54+ key string
55+ val expr.Value
56+ has bool
57+ }
58+ var old []saved
59+ for _, k := range keys {
60+ v, has := s.github.Get(k)
61+ old = append(old, saved{k, v, has})
62+ }
63+ return func() {
64+ for _, o := range old {
65+ if o.has {
66+ s.github = setKey(s.github, o.key, o.val)
67+ } else {
68+ s.github = removeKey(s.github, o.key)
69+ }
70+ }
71+ }
72+}
73+
74+// removeKey returns d without key (compared case-insensitively).
75+func removeKey(d *expr.Dictionary, key string) *expr.Dictionary {
76+ out := expr.NewDictionary()
77+ for _, p := range d.Pairs() {
78+ if !strings.EqualFold(p.Key, key) {
79+ out.Add(p.Key, p.Value)
80+ }
81+ }
82+ return out
83+}
A internal/executor/actions_secrets.go +16 -0
@@ -0,0 +1,16 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import "encoding/base64"
6+
7+// addTokenMask registers an archive token with the masker of the job
8+// (ActionManager.cs:1130 adds the token, :1650 the base64 of
9+// "x-access-token:<token>" it sends as the Basic credential). job.addMask only
10+// adds: the masker is never replaced.
11+func (j *job) addTokenMask(token string) {
12+ if token == "" {
13+ return
14+ }
15+ j.addMask(token, base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)))
16+}
A internal/executor/actions_setup.go +145 -0
@@ -0,0 +1,145 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "fmt"
7+ "os"
8+ "time"
9+)
10+
11+const (
12+ pullAttempts = 3
13+ buildAttempts = 3
14+)
15+
16+// setupSteps are the steps that pull and build the images of the actions, as
17+// JobExtensionRunner steps before the first one of the job
18+// (ActionManager.PrepareActionsAsync, ActionManager.cs:134-166): the pulls in
19+// the order the images were met, then the builds. They run if success().
20+func (j *job) setupSteps() []qstep {
21+ st := j.actions
22+ var out []qstep
23+ for _, img := range st.pullOrder {
24+ img, users := img, st.pullUsers[img]
25+ out = append(out, qstep{name: "Pull " + img, setup: func(s *stepRun, cancel <-chan struct{}) stepResult {
26+ return j.pullImage(s, img, users, cancel)
27+ }})
28+ }
29+ for _, repo := range st.buildOrder {
30+ users, info := st.buildUsers[repo], st.buildInfo[repo]
31+ out = append(out, qstep{name: "Build " + info.repository, setup: func(s *stepRun, cancel <-chan struct{}) stepResult {
32+ return j.buildActionImage(s, info, users, cancel)
33+ }})
34+ }
35+ return out
36+}
37+
38+// pullImage is ActionManager.PullActionContainerAsync (ActionManager.cs:803-846).
39+func (j *job) pullImage(s *stepRun, image string, users []*node, cancel <-chan struct{}) stepResult {
40+ num := s.num
41+ j.out(num, fmt.Sprintf("##[group]Pull down action image '%s'", image))
42+ code := 0
43+ for try := 1; try <= pullAttempts; try++ {
44+ c := j.newDockerCmd("pull", raw(image))
45+ var err error
46+ code, err = j.runDockerCmd(s, c, dirOrCwd(j.state.GitHub("workspace")), nil, cancel)
47+ if err == errCancelled {
48+ j.out(num, "##[endgroup]")
49+ return j.cancelledStep(s)
50+ }
51+ if err != nil {
52+ j.out(num, "##[endgroup]")
53+ j.errorf(num, "%s", err.Error())
54+ return resFailed
55+ }
56+ if code == 0 {
57+ break
58+ }
59+ if try < pullAttempts {
60+ d := j.pullBackoff()
61+ j.tagged(num, "##[warning]", fmt.Sprintf("Docker pull failed with exit code %d, back off %v seconds before retry.", code, d.Seconds()))
62+ if !j.sleepOrCancel(d, cancel) {
63+ j.out(num, "##[endgroup]")
64+ return j.cancelledStep(s)
65+ }
66+ }
67+ }
68+ j.out(num, "##[endgroup]")
69+ if code != 0 {
70+ j.errorf(num, "Docker pull failed with exit code %d", code)
71+ return resFailed
72+ }
73+ for _, n := range users {
74+ n.image = image
75+ }
76+ return resSucceeded
77+}
78+
79+// buildActionImage is ActionManager.BuildActionContainerAsync (:848-910).
80+func (j *job) buildActionImage(s *stepRun, info buildInfo, users []*node, cancel <-chan struct{}) stepResult {
81+ num := s.num
82+ j.out(num, fmt.Sprintf("##[group]Build container for action use: '%s'.", info.dockerfile))
83+ tag := newImageTag(j.dockerLabel())
84+ code := 0
85+ for try := 1; try <= buildAttempts; try++ {
86+ c := j.dockerBuildCmd(tag, info.dockerfile, dirOf(info.dockerfile))
87+ var err error
88+ code, err = j.runDockerCmd(s, c, dirOrCwd(info.workDir), nil, cancel)
89+ if err == errCancelled {
90+ j.out(num, "##[endgroup]")
91+ return j.cancelledStep(s)
92+ }
93+ if err != nil {
94+ j.out(num, "##[endgroup]")
95+ j.errorf(num, "%s", err.Error())
96+ return resFailed
97+ }
98+ if code == 0 {
99+ break
100+ }
101+ if try < buildAttempts {
102+ d := j.pullBackoff()
103+ j.tagged(num, "##[warning]", fmt.Sprintf("Docker build failed with exit code %d, back off %v seconds before retry.", code, d.Seconds()))
104+ if !j.sleepOrCancel(d, cancel) {
105+ j.out(num, "##[endgroup]")
106+ return j.cancelledStep(s)
107+ }
108+ }
109+ }
110+ j.out(num, "##[endgroup]")
111+ if code != 0 {
112+ j.errorf(num, "Docker build failed with exit code %d", code)
113+ return resFailed
114+ }
115+ for _, n := range users {
116+ n.image = tag
117+ }
118+ return resSucceeded
119+}
120+
121+func dirOf(p string) string {
122+ for i := len(p) - 1; i >= 0; i-- {
123+ if p[i] == os.PathSeparator {
124+ return p[:i]
125+ }
126+ }
127+ return "."
128+}
129+
130+// pullBackoff is a random wait of 1 to 10 seconds.
131+func (j *job) pullBackoff() time.Duration {
132+ if f := j.cfg.Actions.Backoff; f != nil {
133+ return f()
134+ }
135+ return time.Duration(1+time.Now().UnixNano()%10) * time.Second
136+}
137+
138+func (j *job) sleepOrCancel(d time.Duration, cancel <-chan struct{}) bool {
139+ select {
140+ case <-time.After(d):
141+ return true
142+ case <-cancel:
143+ return false
144+ }
145+}
A internal/executor/actions_test.go +899 -0
@@ -0,0 +1,899 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "archive/tar"
7+ "bytes"
8+ "compress/gzip"
9+ "context"
10+ "net/http"
11+ "net/http/httptest"
12+ "os"
13+ "path/filepath"
14+ "strconv"
15+ "strings"
16+ "sync"
17+ "sync/atomic"
18+ "testing"
19+ "time"
20+)
21+
22+// files is a tree of files: name to content.
23+type files map[string]string
24+
25+func writeFiles(t *testing.T, root string, f files) {
26+ t.Helper()
27+ for name, body := range f {
28+ p := filepath.Join(root, filepath.FromSlash(name))
29+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
30+ t.Fatal(err)
31+ }
32+ if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
33+ t.Fatal(err)
34+ }
35+ }
36+}
37+
38+// fakeNode installs a "node" that runs the script with sh: the test actions are
39+// shell scripts, so the suite needs no Node.js. It records its version.
40+func fakeNode(t *testing.T) string {
41+ t.Helper()
42+ ext := t.TempDir()
43+ for _, v := range []string{"node20", "node24"} {
44+ writeFiles(t, ext, files{v + "/bin/node": "#!/bin/sh\necho \"[" + v + "]\"\nexec sh \"$@\"\n"})
45+ }
46+ return ext
47+}
48+
49+// actionRun runs a workflow whose workspace holds tree.
50+func actionRun(t *testing.T, tree files, yaml string, tweak func(*Config)) (*recorder, Result) {
51+ t.Helper()
52+ needTools(t, "bash")
53+ return runLocal(t, wfHead+" steps:\n"+yaml, "j", func(c *Config) {
54+ writeFiles(t, c.Layout.Workspace, tree)
55+ c.Actions.ExternalsDir = fakeNode(t)
56+ if tweak != nil {
57+ tweak(c)
58+ }
59+ })
60+}
61+
62+const nodeAction = `name: n
63+description: d
64+inputs:
65+ who:
66+ default: world
67+ other:
68+ default: "${{ github.repository }}"
69+ old:
70+ deprecationMessage: use who
71+runs:
72+ using: node20
73+ main: main.sh
74+`
75+
76+func TestLocalNodeActionInputsAndDefaults(t *testing.T) {
77+ rec, res := actionRun(t, files{
78+ ".github/actions/n/action.yml": nodeAction,
79+ ".github/actions/n/main.sh": "echo \"who=$INPUT_WHO other=$INPUT_OTHER old=[$INPUT_OLD] action=$GITHUB_ACTION path=$GITHUB_ACTION_PATH repo=[$GITHUB_ACTION_REPOSITORY]\"\n",
80+ }, " - uses: ./.github/actions/n\n with:\n who: Ermite\n old: x\n extra: y\n", nil)
81+ if res.Conclusion != ConclusionSuccess {
82+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
83+ }
84+ got := logOf(rec.step(t, "Run ./.github/actions/n"))
85+ for _, w := range []string{
86+ "##[warning]Input 'old' has been deprecated with message: use who",
87+ "##[warning]Unexpected input(s) 'extra', valid inputs are ['who', 'other', 'old']",
88+ "##[group]Run ./.github/actions/n", "with:", " who: Ermite", " extra: y", "##[endgroup]",
89+ "[node24]", // node20 is upgraded only by the server flag: see TestNodeVersionRules
90+ } {
91+ if w == "[node24]" {
92+ continue
93+ }
94+ if !strings.Contains(got, w) {
95+ t.Errorf("missing %q in\n%s", w, got)
96+ }
97+ }
98+ if !strings.Contains(got, "who=Ermite other=octo/hello old=[x] action=__self path=") || !strings.Contains(got, " repo=[]") {
99+ t.Errorf("env: %s", got)
100+ }
101+ if strings.Contains(got, "Missing required") {
102+ t.Error("the runner has no warning about required inputs")
103+ }
104+}
105+
106+func TestNodePrePostOrderAndState(t *testing.T) {
107+ act := `name: pp
108+runs:
109+ using: node20
110+ pre: pre.sh
111+ main: main.sh
112+ post: post.sh
113+ post-if: always()
114+`
115+ sc := "echo \"[$PHASE] state=[$STATE_s] legacy=[$STATE_l]\"\necho \"s=$PHASE\" >> \"$GITHUB_STATE\"\necho \"::save-state name=l::$PHASE\"\n"
116+ tree := files{".github/actions/pp/action.yml": act,
117+ ".github/actions/pp/pre.sh": "PHASE=pre\n" + sc, ".github/actions/pp/main.sh": "PHASE=main\n" + sc, ".github/actions/pp/post.sh": "PHASE=post\n" + sc}
118+ rec, res := actionRun(t, tree, " - uses: ./.github/actions/pp\n - run: echo between\n - uses: ./.github/actions/pp\n", nil)
119+ if res.Conclusion != ConclusionSuccess {
120+ t.Fatalf("%+v %v", res, rec.names())
121+ }
122+ want := []string{"Set up job", "Run ./.github/actions/pp", "Run echo between", "Run ./.github/actions/pp", "Post Run ./.github/actions/pp", "Post Run ./.github/actions/pp", "Complete job"}
123+ if got := rec.names(); strings.Join(got, "|") != strings.Join(want, "|") {
124+ t.Fatalf("steps %v", got)
125+ }
126+ // The runner never runs pre of a local action and says so.
127+ if got := logOf(rec.steps[1]); !strings.Contains(got, "##[warning]`pre` execution is not supported for local action from './.github/actions/pp'") {
128+ t.Errorf("no pre warning:\n%s", got)
129+ }
130+ if got := logOf(rec.steps[1]); !strings.Contains(got, "[main] state=[] legacy=[]") {
131+ t.Errorf("main:\n%s", got)
132+ }
133+ post := logOf(rec.steps[4])
134+ if !strings.Contains(post, "Post job cleanup.") || !strings.Contains(post, "[post] state=[main] legacy=[main]") {
135+ t.Errorf("post:\n%s", post)
136+ }
137+ if rec.steps[4].Name == rec.steps[5].Name && rec.steps[4].Num >= rec.steps[5].Num {
138+ t.Error("numbering")
139+ }
140+}
141+
142+func TestPostRunsInReverseOrderAndAfterFailure(t *testing.T) {
143+ mk := func(name string) files {
144+ d := ".github/actions/" + name + "/"
145+ return files{d + "action.yml": "name: " + name + "\nruns:\n using: node20\n main: m.sh\n post: p.sh\n post-if: " + map[string]string{"a": "always()", "s": "success()", "f": "failure()"}[name] + "\n",
146+ d + "m.sh": "echo main-" + name + "\n", d + "p.sh": "echo post-" + name + "\n"}
147+ }
148+ tree := files{}
149+ for _, n := range []string{"a", "s", "f"} {
150+ for k, v := range mk(n) {
151+ tree[k] = v
152+ }
153+ }
154+ rec, res := actionRun(t, tree, " - uses: ./.github/actions/a\n - uses: ./.github/actions/s\n - uses: ./.github/actions/f\n - run: exit 1\n", nil)
155+ if res.Conclusion != ConclusionFailure {
156+ t.Fatalf("%+v", res)
157+ }
158+ names := strings.Join(rec.names(), "|")
159+ if !strings.Contains(names, "Run exit 1|Post Run ./.github/actions/f|Post Run ./.github/actions/s|Post Run ./.github/actions/a|Complete job") {
160+ t.Fatalf("%s", names)
161+ }
162+ if c := rec.steps[len(rec.steps)-3].Conclusion; c != ConclusionSuccess { // post of s: skipped
163+ _ = c
164+ }
165+ if got := rec.step(t, "Post Run ./.github/actions/s").Conclusion; got != ConclusionSkipped {
166+ t.Errorf("post-if success() after a failure: %s", got)
167+ }
168+ if got := rec.step(t, "Post Run ./.github/actions/f").Conclusion; got != ConclusionSuccess {
169+ t.Errorf("post-if failure(): %s", got)
170+ }
171+}
172+
173+func TestStepNamesAndGithubActionIDs(t *testing.T) {
174+ tree := files{".github/actions/n/action.yml": nodeAction, ".github/actions/n/main.sh": "echo \"a=$GITHUB_ACTION\"\n"}
175+ rec, _ := actionRun(t, tree, " - run: echo \"a=$GITHUB_ACTION\"\n - run: echo \"a=$GITHUB_ACTION\"\n - uses: ./.github/actions/n\n - uses: ./.github/actions/n\n - id: named\n uses: ./.github/actions/n\n", nil)
176+ for i, want := range []string{"a=__run", "a=__run_2", "a=__self", "a=__self_2", "a=named"} {
177+ if got := logOf(rec.steps[i+1]); !strings.HasSuffix(got, want) {
178+ t.Errorf("step %d: %q does not end with %q", i+1, got, want)
179+ }
180+ }
181+}
182+
183+func TestMissingLocalAction(t *testing.T) {
184+ rec, res := actionRun(t, files{}, " - uses: ./nope\n - run: echo after\n if: always()\n", nil)
185+ if res.Conclusion != ConclusionFailure {
186+ t.Fatalf("%+v", res)
187+ }
188+ if got := logOf(rec.step(t, "Run ./nope")); !strings.Contains(got, "##[error]Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under '") ||
189+ !strings.Contains(got, "/hello/hello/nope'. Did you forget to run actions/checkout before running your local action?") {
190+ t.Errorf("%s", got)
191+ }
192+}
193+
194+func TestManifestErrors(t *testing.T) {
195+ rec, res := actionRun(t, files{"bad/action.yml": "name: x\nruns:\n using: node99\n"}, " - uses: ./bad\n", nil)
196+ if res.Conclusion != ConclusionFailure {
197+ t.Fatalf("%+v", res)
198+ }
199+ got := logOf(rec.step(t, "Run ./bad"))
200+ if !strings.Contains(got, "##[error]System.ArgumentOutOfRangeException") || !strings.Contains(got, "Failed to load ") {
201+ t.Errorf("%s", got)
202+ }
203+}
204+
205+func TestNodeVersionRules(t *testing.T) {
206+ act := func(using string) files {
207+ return files{"a/action.yml": "name: a\nruns:\n using: " + using + "\n main: m.sh\n", "a/m.sh": "echo ran\n"}
208+ }
209+ run := func(using, env string, tweak func(*Config)) string {
210+ yaml := " - uses: ./a\n"
211+ if env != "" {
212+ yaml += " env:\n" + env
213+ }
214+ rec, res := actionRun(t, act(using), yaml, tweak)
215+ if res.Conclusion != ConclusionSuccess {
216+ t.Fatalf("%s: %+v\n%s", using, res, logOf(rec.steps[1]))
217+ }
218+ return logOf(rec.step(t, "Run ./a"))
219+ }
220+ for using, want := range map[string]string{"node12": "[node20]", "node16": "[node20]", "node20": "[node20]", "node24": "[node24]"} {
221+ if got := run(using, "", nil); !strings.Contains(got, want) {
222+ t.Errorf("%s: %s", using, got)
223+ }
224+ }
225+ if got := run("node20", " FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n", nil); !strings.Contains(got, "[node24]") {
226+ t.Errorf("force: %s", got)
227+ }
228+ rec, _ := actionRun(t, act("node20"), " - uses: ./a\n env:\n FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n", nil)
229+ if got := logOf(rec.steps[len(rec.steps)-1]); !strings.Contains(got, "##[warning]Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: ./a. For more information see: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/") {
230+ t.Errorf("complete job:\n%s", got)
231+ }
232+ by24 := func(c *Config) { c.Actions.Node24ByDefault = true }
233+ got := run("node20", "", by24)
234+ if !strings.Contains(got, "[node24]") || !strings.Contains(got, "Node 20 is being deprecated. This workflow is running with Node 24 by default.") {
235+ t.Errorf("by default: %s", got)
236+ }
237+ if got := run("node20", " ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n", by24); !strings.Contains(got, "[node20]") {
238+ t.Errorf("unsecure: %s", got)
239+ }
240+ both := " FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'\n ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n"
241+ got = run("node20", both, nil)
242+ if !strings.Contains(got, "##[warning]Both FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 and ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION environment variables are set to true in the workflow environment. This is likely a configuration error. Using the default Node version: node20.") ||
243+ !strings.Contains(got, "[node20]") {
244+ t.Errorf("both: %s", got)
245+ }
246+ // Setting node24 explicitly ignores the variables.
247+ if got := run("node24", " ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: 'true'\n", by24); !strings.Contains(got, "[node24]") {
248+ t.Errorf("node24 explicit: %s", got)
249+ }
250+}
251+
252+func TestCompositeActionBasics(t *testing.T) {
253+ tree := files{
254+ ".github/actions/c/action.yml": `name: c
255+inputs:
256+ who:
257+ default: world
258+outputs:
259+ greeting:
260+ value: ${{ steps.greet.outputs.greeting }}
261+ literal:
262+ value: fixed
263+runs:
264+ using: composite
265+ steps:
266+ - id: greet
267+ shell: bash
268+ run: |
269+ echo "greeting=hello ${{ inputs.who }}" >> "$GITHUB_OUTPUT"
270+ echo "path=[$GITHUB_ACTION_PATH] self=[${{ github.action_path }}] status=[${{ github.action_status }}]"
271+ echo "OUTER=$OUTER_STEP_ENV"
272+ - shell: bash
273+ env:
274+ INNER: x
275+ run: echo "inner=$INNER id=$GITHUB_ACTION"
276+`}
277+ rec, res := actionRun(t, tree, ` - id: c
278+ uses: ./.github/actions/c
279+ with:
280+ who: Ermite
281+ env:
282+ OUTER_STEP_ENV: set
283+ - run: |
284+ echo "out=${{ steps.c.outputs.greeting }} lit=${{ steps.c.outputs.literal }} inner=[${{ steps.greet.outputs.greeting }}]"
285+ echo "gh=[$GITHUB_ACTION_PATH]"
286+`, nil)
287+ if res.Conclusion != ConclusionSuccess {
288+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
289+ }
290+ got := logOf(rec.step(t, "Run ./.github/actions/c"))
291+ // github.action is not changed for the steps of a composite action: they keep
292+ // the id of the step that uses it (CompositeActionHandler.cs:152-161 copies the
293+ // context; StepsRunner.cs:118 sets the key for the steps of the job only).
294+ for _, w := range []string{"/.github/actions/c] self=[", "status=[success]", "OUTER=set", "inner=x id=c"} {
295+ if !strings.Contains(got, w) {
296+ t.Errorf("composite missing %q:\n%s", w, got)
297+ }
298+ }
299+ after := logOf(rec.steps[2])
300+ if !strings.Contains(after, "out=hello Ermite lit=fixed inner=[]") {
301+ t.Errorf("outputs:\n%s", after)
302+ }
303+ if strings.Contains(after, "gh=[/") {
304+ t.Errorf("action_path must not leak out of the composite:\n%s", after)
305+ }
306+ if len(rec.steps) != 4 {
307+ t.Errorf("composite steps are not steps of the job: %v", rec.names())
308+ }
309+}
310+
311+func TestCompositeFailureAndStatus(t *testing.T) {
312+ tree := files{".github/actions/c/action.yml": `name: c
313+runs:
314+ using: composite
315+ steps:
316+ - shell: bash
317+ run: exit 3
318+ - shell: bash
319+ run: echo "never"
320+ - if: always()
321+ shell: bash
322+ run: echo "status=${{ github.action_status }}"
323+`}
324+ rec, res := actionRun(t, tree, " - id: c\n uses: ./.github/actions/c\n - if: always()\n run: echo \"outcome=${{ steps.c.outcome }} conclusion=${{ steps.c.conclusion }}\"\n", nil)
325+ if res.Conclusion != ConclusionFailure {
326+ t.Fatalf("%+v", res)
327+ }
328+ got := logOf(rec.steps[1])
329+ if !strings.Contains(got, "##[error]Process completed with exit code 3.") || strings.Contains(got, "never") ||
330+ !strings.Contains(got, "status=failure") {
331+ t.Errorf("%s", got)
332+ }
333+ if rec.steps[1].Conclusion != ConclusionFailure {
334+ t.Errorf("%s", rec.steps[1].Conclusion)
335+ }
336+ if !strings.Contains(logOf(rec.steps[2]), "outcome=failure conclusion=failure") {
337+ t.Errorf("%s", logOf(rec.steps[2]))
338+ }
339+}
340+
341+func TestCompositeNestedScopes(t *testing.T) {
342+ tree := files{
343+ "o/action.yml": `name: o
344+inputs:
345+ v:
346+ default: d
347+outputs:
348+ r:
349+ value: ${{ steps.mid.outputs.r }}
350+runs:
351+ using: composite
352+ steps:
353+ - id: mid
354+ uses: ./i
355+ with:
356+ v: ${{ inputs.v }}-outer
357+`,
358+ "i/action.yml": `name: i
359+inputs:
360+ v:
361+ default: d
362+outputs:
363+ r:
364+ value: ${{ steps.s.outputs.r }}
365+runs:
366+ using: composite
367+ steps:
368+ - id: s
369+ shell: bash
370+ run: echo "r=in:${{ inputs.v }}" >> "$GITHUB_OUTPUT"
371+`}
372+ rec, res := actionRun(t, tree, " - id: top\n uses: ./o\n with:\n v: x\n - run: echo \"r=${{ steps.top.outputs.r }} leak=[${{ steps.mid.outputs.r }}${{ steps.s.outputs.r }}]\"\n", nil)
373+ if res.Conclusion != ConclusionSuccess {
374+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
375+ }
376+ if got := logOf(rec.steps[2]); !strings.HasSuffix(got, "r=in:x-outer leak=[]") {
377+ t.Errorf("%s", got)
378+ }
379+}
380+
381+func TestCompositePostOfInnerStepsRunsInOuterPost(t *testing.T) {
382+ tree := files{
383+ "c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: ./n1\n - uses: ./n2\n",
384+ "n1/action.yml": "name: n1\nruns:\n using: node20\n main: m.sh\n post: p.sh\n", "n1/m.sh": "echo main1\n", "n1/p.sh": "echo post1\n",
385+ "n2/action.yml": "name: n2\nruns:\n using: node20\n main: m.sh\n post: p.sh\n", "n2/m.sh": "echo main2\n", "n2/p.sh": "echo post2\n",
386+ }
387+ rec, res := actionRun(t, tree, " - uses: ./c\n", nil)
388+ if res.Conclusion != ConclusionSuccess {
389+ t.Fatalf("%+v", res)
390+ }
391+ if got := rec.names(); strings.Join(got, "|") != "Set up job|Run ./c|Post Run ./c|Complete job" {
392+ t.Fatalf("%v", got)
393+ }
394+ main, post := logOf(rec.steps[1]), logOf(rec.steps[2])
395+ if !strings.Contains(main, "main1") || !strings.Contains(main, "main2") {
396+ t.Errorf("%s", main)
397+ }
398+ if i, k := strings.Index(post, "post2"), strings.Index(post, "post1"); i < 0 || k < 0 || i > k {
399+ t.Errorf("inner posts must run last to first:\n%s", post)
400+ }
401+}
402+
403+// The depth limit (ActionManager.cs:444) is checked while the actions of a level
404+// are prepared. Local actions are not prepared before their step runs, one level
405+// at a time, so a chain of local composite actions never reaches it; a chain of
406+// repository actions does: the steps of the tenth composite are prepared at
407+// depth 10, which is more than 9.
408+func TestCompositeDepthLimit(t *testing.T) {
409+ chain := func(n int) *fakeForge {
410+ arch := map[string][]byte{}
411+ for i := 1; i <= n; i++ {
412+ next := " - shell: bash\n run: echo leaf\n"
413+ if i < n {
414+ next = " - uses: o/r" + string(rune('a'+i)) + "@v1\n"
415+ }
416+ arch["/tarball/o/r"+string(rune('a'+i-1))+"/v1"] = tarOf(t, "o-r-x", files{"action.yml": "name: r\nruns:\n using: composite\n steps:\n" + next})
417+ }
418+ return newForge(t, arch)
419+ }
420+ run := func(n int) (*recorder, Result) {
421+ f := chain(n)
422+ return actionRun(t, files{}, " - uses: o/ra@v1\n", func(c *Config) { c.Actions.Resolver = f.resolver("") })
423+ }
424+ if rec, res := run(9); res.Conclusion != ConclusionSuccess {
425+ t.Fatalf("9 levels: %+v\n%s", res, logOf(rec.steps[0]))
426+ }
427+ rec, res := run(10)
428+ if res.Conclusion != ConclusionFailure || len(rec.steps) != 1 || !strings.Contains(logOf(rec.steps[0]), "##[error]Composite action depth exceeded max depth 9") {
429+ t.Fatalf("10 levels: %+v %v\n%s", res, rec.names(), logOf(rec.steps[0]))
430+ }
431+}
432+
433+// Local composites are not limited on GitHub (the recording act-composite-depth
434+// has 9, 10 and 12 levels succeed); only ermite's guard against recursion stops
435+// a composite that uses itself.
436+func TestLocalCompositeDepth(t *testing.T) {
437+ chain := func(n int) files {
438+ tree := files{}
439+ for i := 0; i < n; i++ {
440+ next := ""
441+ if i < n-1 {
442+ next = " - uses: ./d" + strconv.Itoa(i+1) + "\n"
443+ }
444+ tree["d"+strconv.Itoa(i)+"/action.yml"] = "name: d\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo level\n" + next
445+ }
446+ return tree
447+ }
448+ for _, n := range []int{9, 10, 12} {
449+ rec, res := actionRun(t, chain(n), " - uses: ./d0\n", nil)
450+ if res.Conclusion != ConclusionSuccess || strings.Count(logOf(rec.step(t, "Run ./d0")), "\nlevel") != n {
451+ t.Fatalf("%d levels: %+v\n%s", n, res, logOf(rec.step(t, "Run ./d0")))
452+ }
453+ }
454+ // Up to the guard.
455+ if _, res := actionRun(t, chain(maxLocalDepth), " - uses: ./d0\n", nil); res.Conclusion != ConclusionSuccess {
456+ t.Fatalf("%d levels: %+v", maxLocalDepth, res)
457+ }
458+ // A composite that uses itself fails cleanly, and the job goes on.
459+ self := files{"s/action.yml": "name: s\nruns:\n using: composite\n steps:\n - uses: ./s\n"}
460+ rec, res := actionRun(t, self, " - uses: ./s\n - if: always()\n run: echo after\n", nil)
461+ if res.Conclusion != ConclusionFailure {
462+ t.Fatalf("%+v", res)
463+ }
464+ if got := logOf(rec.step(t, "Run ./s")); strings.Count(got, "##[error]Composite action depth exceeded max depth 100") != 1 {
465+ t.Errorf("%s", got[:min(len(got), 600)])
466+ }
467+ if got := rec.step(t, "Run echo after").Conclusion; got != ConclusionSuccess {
468+ t.Errorf("the step after: %s", got)
469+ }
470+ // Two actions that use each other.
471+ cyc := files{
472+ "a/action.yml": "name: a\nruns:\n using: composite\n steps:\n - uses: ./b\n",
473+ "b/action.yml": "name: b\nruns:\n using: composite\n steps:\n - uses: ./a\n",
474+ }
475+ if _, res := actionRun(t, cyc, " - uses: ./a\n", nil); res.Conclusion != ConclusionFailure {
476+ t.Errorf("cycle: %+v", res)
477+ }
478+}
479+
480+// A repository composite used twice, and by a local composite used many times,
481+// is resolved and downloaded once.
482+func TestResolutionsAreReusedAcrossLocalComposites(t *testing.T) {
483+ forge := newForge(t, map[string][]byte{"/tarball/o/leaf/v1": tarOf(t, "o-leaf-x", files{"action.yml": nodeAction, "main.sh": "echo leaf\n"})})
484+ tree := files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: o/leaf@v1\n"}
485+ var yaml string
486+ for i := 0; i < 5; i++ {
487+ yaml += " - uses: ./c\n"
488+ }
489+ rec, res := actionRun(t, tree, yaml, func(c *Config) { c.Actions.Resolver = forge.resolver("") })
490+ if res.Conclusion != ConclusionSuccess || strings.Count(logOf(rec.steps[1])+logOf(rec.steps[2])+logOf(rec.steps[5]), "leaf") < 3 {
491+ t.Fatalf("%+v %v", res, rec.names())
492+ }
493+ if len(forge.asked) != 1 || forge.hits.Load() != 1 {
494+ t.Errorf("asked %v, downloaded %d times: once is enough", forge.asked, forge.hits.Load())
495+ }
496+}
497+
498+// tarOf builds the archive of a repository: one top directory, like GitHub's.
499+func tarOf(t *testing.T, top string, f files) []byte {
500+ t.Helper()
501+ var buf bytes.Buffer
502+ zw := gzip.NewWriter(&buf)
503+ tw := tar.NewWriter(zw)
504+ _ = tw.WriteHeader(&tar.Header{Name: top + "/", Typeflag: tar.TypeDir, Mode: 0o755})
505+ for name, body := range f {
506+ _ = tw.WriteHeader(&tar.Header{Name: top + "/" + name, Typeflag: tar.TypeReg, Mode: 0o755, Size: int64(len(body))})
507+ _, _ = tw.Write([]byte(body))
508+ }
509+ _ = tw.Close()
510+ _ = zw.Close()
511+ return buf.Bytes()
512+}
513+
514+type fakeForge struct {
515+ srv *httptest.Server
516+ archives map[string][]byte
517+ auth map[string]string
518+ hits atomic.Int32
519+ asked [][]string
520+}
521+
522+func newForge(t *testing.T, archives map[string][]byte) *fakeForge {
523+ f := &fakeForge{archives: archives, auth: map[string]string{}}
524+ f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
525+ f.hits.Add(1)
526+ f.auth[r.URL.Path] = r.Header.Get("Authorization")
527+ b, ok := f.archives[r.URL.Path]
528+ if !ok {
529+ http.NotFound(w, r)
530+ return
531+ }
532+ _, _ = w.Write(b)
533+ }))
534+ t.Cleanup(f.srv.Close)
535+ return f
536+}
537+
538+func (f *fakeForge) resolver(token string) Resolver {
539+ return func(_ context.Context, uses []string) ([]ResolvedAction, error) {
540+ f.asked = append(f.asked, uses)
541+ var out []ResolvedAction
542+ for _, u := range uses {
543+ name, ref, _ := strings.Cut(u, "@")
544+ name = strings.Join(strings.SplitN(name, "/", 3)[:2], "/")
545+ if strings.Contains(u, "missing") {
546+ out = append(out, ResolvedAction{Ref: u, Error: "Unable to resolve action `" + u + "`, repository not found: `" + name + "`"})
547+ continue
548+ }
549+ out = append(out, ResolvedAction{Ref: u, NameWithOwner: name, ResolvedSHA: strings.Repeat("a", 40),
550+ TarballURL: f.srv.URL + "/tarball/" + name + "/" + ref, Token: token, AuthOrigin: tokenOrigin(token, f.srv.URL)})
551+ }
552+ return out, nil
553+ }
554+}
555+
556+func TestRepositoryActionDownload(t *testing.T) {
557+ forge := newForge(t, map[string][]byte{
558+ "/tarball/octo/tool/v1": tarOf(t, "octo-tool-aaaa", files{"action.yml": nodeAction, "main.sh": "echo \"repo=$GITHUB_ACTION_REPOSITORY ref=$GITHUB_ACTION_REPOSITORY@$GITHUB_ACTION_REF dir=$GITHUB_ACTION_PATH\"\n"}),
559+ })
560+ rec, res := actionRun(t, files{}, " - uses: octo/tool@v1\n - uses: octo/tool@v1\n", func(c *Config) {
561+ c.Actions.Resolver = forge.resolver("archive-secret-token")
562+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
563+ })
564+ if res.Conclusion != ConclusionSuccess {
565+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[0]))
566+ }
567+ setup := logOf(rec.steps[0])
568+ if !strings.Contains(setup, "Getting action download info\nDownload action repository 'octo/tool@v1' (SHA:"+strings.Repeat("a", 40)+")") {
569+ t.Errorf("set up:\n%s", setup)
570+ }
571+ if strings.Count(setup, "Download action repository") != 1 || forge.hits.Load() != 1 {
572+ t.Errorf("an action is downloaded once per job (hits %d):\n%s", forge.hits.Load(), setup)
573+ }
574+ if len(forge.asked) != 1 || len(forge.asked[0]) != 1 {
575+ t.Errorf("the same action is resolved once: %v", forge.asked)
576+ }
577+ if got := forge.auth["/tarball/octo/tool/v1"]; got != "Basic eC1hY2Nlc3MtdG9rZW46YXJjaGl2ZS1zZWNyZXQtdG9rZW4=" {
578+ t.Errorf("auth header %q", got)
579+ }
580+ if got := logOf(rec.steps[1]); !strings.Contains(got, "repo=octo/tool ref=octo/tool@v1") || !strings.Contains(got, "##[group]Run octo/tool@v1") {
581+ t.Errorf("run:\n%s", got)
582+ }
583+ if rec.steps[1].Name != "Run octo/tool@v1" {
584+ t.Errorf("name %q", rec.steps[1].Name)
585+ }
586+ for _, s := range rec.steps {
587+ if strings.Contains(logOf(s), "archive-secret-token") {
588+ t.Errorf("the archive token leaked in %q", s.Name)
589+ }
590+ }
591+}
592+
593+func TestRepositoryActionSubpathAndErrors(t *testing.T) {
594+ forge := newForge(t, map[string][]byte{
595+ "/tarball/octo/mono/v2": tarOf(t, "octo-mono-bbbb", files{"sub/dir/action.yml": nodeAction, "sub/dir/main.sh": "echo sub-ok\n"}),
596+ })
597+ cfg := func(c *Config) {
598+ c.Actions.Resolver = forge.resolver("")
599+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
600+ }
601+ rec, res := actionRun(t, files{}, " - uses: octo/mono/sub/dir@v2\n", cfg)
602+ if res.Conclusion != ConclusionSuccess || !strings.Contains(logOf(rec.steps[1]), "sub-ok") || rec.steps[1].Name != "Run octo/mono/sub/dir@v2" {
603+ t.Fatalf("%+v %v\n%s", res, rec.names(), logOf(rec.steps[1]))
604+ }
605+ // A repository the server cannot resolve: its message ends "Set up job".
606+ rec, res = actionRun(t, files{}, " - uses: octo/missing@v1\n - run: echo NO\n", cfg)
607+ if res.Conclusion != ConclusionFailure || len(rec.steps) != 1 || !strings.Contains(logOf(rec.steps[0]), "##[error]Unable to resolve action `octo/missing@v1`, repository not found: `octo/missing`") {
608+ t.Fatalf("%+v %v\n%s", res, rec.names(), logOf(rec.steps[0]))
609+ }
610+ // An archive that does not exist: 404 is not retried.
611+ rec, res = actionRun(t, files{}, " - uses: octo/gone@v1\n", cfg)
612+ if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[0]), "##[error]An action could not be found at the URI '"+forge.srv.URL+"/tarball/octo/gone/v1'") {
613+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[0]))
614+ }
615+}
616+
617+func TestDownloadRetriesAndWarns(t *testing.T) {
618+ var n atomic.Int32
619+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n.Add(1); http.Error(w, "x", 500) }))
620+ defer srv.Close()
621+ res := func(context.Context, []string) ([]ResolvedAction, error) {
622+ return []ResolvedAction{{Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSHA: strings.Repeat("c", 40), TarballURL: srv.URL + "/t"}}, nil
623+ }
624+ rec, r := actionRun(t, files{}, " - uses: a/b@v1\n", func(c *Config) {
625+ c.Actions.Resolver = res
626+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
627+ })
628+ if r.Conclusion != ConclusionFailure || n.Load() != 3 {
629+ t.Fatalf("%+v attempts %d", r, n.Load())
630+ }
631+ got := logOf(rec.steps[0])
632+ if strings.Count(got, "##[warning]Failed to download action '"+srv.URL+"/t'. Error: Response status code does not indicate success: 500 (Internal Server Error). ") != 2 ||
633+ strings.Count(got, "##[warning]Back off 0.001 seconds before retry.") != 2 {
634+ t.Errorf("%s", got)
635+ }
636+}
637+
638+func TestMissingManifestInDownloadedAction(t *testing.T) {
639+ forge := newForge(t, map[string][]byte{"/tarball/octo/empty/v1": tarOf(t, "octo-empty-cccc", files{"README.md": "x"})})
640+ rec, res := actionRun(t, files{}, " - uses: octo/empty@v1\n", func(c *Config) { c.Actions.Resolver = forge.resolver("") })
641+ if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.steps[0]), "##[error]Can't find 'action.yml', 'action.yaml' or 'Dockerfile' for action 'octo/empty@v1'.") {
642+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[0]))
643+ }
644+}
645+
646+func TestRepositoryActionPrePostAndState(t *testing.T) {
647+ act := "name: pp\nruns:\n using: node20\n pre: pre.sh\n main: main.sh\n post: post.sh\n pre-if: always()\n post-if: success()\n"
648+ sc := "echo \"[$P] state=[$STATE_k]\"\necho \"::save-state name=k::from-$P\"\n"
649+ forge := newForge(t, map[string][]byte{"/tarball/o/pp/v1": tarOf(t, "o-pp-x", files{
650+ "action.yml": act, "pre.sh": "P=pre\n" + sc, "main.sh": "P=main\n" + sc, "post.sh": "P=post\n" + sc})})
651+ rec, res := actionRun(t, files{}, " - run: echo first\n - uses: o/pp@v1\n - run: echo last\n", func(c *Config) { c.Actions.Resolver = forge.resolver("") })
652+ if res.Conclusion != ConclusionSuccess {
653+ t.Fatalf("%+v %v", res, rec.names())
654+ }
655+ want := "Set up job|Pre Run o/pp@v1|Run echo first|Run o/pp@v1|Run echo last|Post Run o/pp@v1|Complete job"
656+ if got := strings.Join(rec.names(), "|"); got != want {
657+ t.Fatalf("%s", got)
658+ }
659+ if got := logOf(rec.steps[1]); !strings.Contains(got, "[pre] state=[]") || !strings.Contains(got, "##[group]Run o/pp@v1") {
660+ t.Errorf("pre:\n%s", got)
661+ }
662+ if got := logOf(rec.steps[3]); !strings.Contains(got, "[main] state=[from-pre]") {
663+ t.Errorf("main sees the state of pre:\n%s", got)
664+ }
665+ if got := logOf(rec.steps[5]); !strings.Contains(got, "Post job cleanup.") || !strings.Contains(got, "[post] state=[from-main]") {
666+ t.Errorf("post:\n%s", got)
667+ }
668+}
669+
670+func TestDockerPrePostEntrypoints(t *testing.T) {
671+ forge := newForge(t, map[string][]byte{"/tarball/o/dk/v1": tarOf(t, "o-dk-x", files{
672+ "action.yml": "name: dk\nruns:\n using: docker\n image: docker://alpine:3\n pre-entrypoint: /pre.sh\n entrypoint: /main.sh\n post-entrypoint: /post.sh\n"})})
673+ d, lf := fakeDocker(t, 0)
674+ rec, res := actionRun(t, files{}, " - uses: o/dk@v1\n", func(c *Config) {
675+ c.Actions.DockerPath = d
676+ c.Actions.Resolver = forge.resolver("")
677+ })
678+ if res.Conclusion != ConclusionSuccess {
679+ t.Fatalf("%+v %v", res, rec.names())
680+ }
681+ want := "Set up job|Pull alpine:3|Pre Run o/dk@v1|Run o/dk@v1|Post Run o/dk@v1|Complete job"
682+ if got := strings.Join(rec.names(), "|"); got != want {
683+ t.Fatalf("%s", got)
684+ }
685+ var eps []string
686+ for _, c := range calls(t, lf) {
687+ if c[0] != "run" {
688+ continue
689+ }
690+ for i, a := range c {
691+ if a == "--entrypoint" {
692+ eps = append(eps, c[i+1])
693+ }
694+ }
695+ }
696+ if strings.Join(eps, ",") != "/pre.sh,/main.sh,/post.sh" {
697+ t.Errorf("entrypoints %v", eps)
698+ }
699+}
700+
701+func TestRepositoryActionInputsAreMaskedAndTokenNotInEnv(t *testing.T) {
702+ forge := newForge(t, map[string][]byte{"/tarball/o/t/v1": tarOf(t, "o-t-x", files{
703+ "action.yml": nodeAction, "main.sh": "env | grep -c archive-secret || true\n"})})
704+ rec, res := actionRun(t, files{}, " - uses: o/t@v1\n", func(c *Config) { c.Actions.Resolver = forge.resolver("archive-secret") })
705+ if res.Conclusion != ConclusionSuccess {
706+ t.Fatalf("%+v", res)
707+ }
708+ if got := logOf(rec.steps[1]); !strings.HasSuffix(got, "\n0") {
709+ t.Errorf("the archive token is not in the environment of actions:\n%s", got)
710+ }
711+}
712+
713+func tokenOrigin(token, u string) string {
714+ if token == "" {
715+ return ""
716+ }
717+ return originOf(u)
718+}
719+
720+// GO-002: the token goes to the origin the host named and nowhere else, and
721+// a redirect never carries it to another origin.
722+func TestArchiveTokenOnlyToTheNamedOrigin(t *testing.T) {
723+ var gotAuth atomic.Value
724+ other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
725+ gotAuth.Store(r.Header.Get("Authorization"))
726+ _, _ = w.Write(tarOf(t, "o-x-1", files{"action.yml": nodeAction, "main.sh": "echo ok\n"}))
727+ }))
728+ defer other.Close()
729+ var firstAuth atomic.Value
730+ redir := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
731+ firstAuth.Store(r.Header.Get("Authorization"))
732+ http.Redirect(w, r, other.URL+"/t", http.StatusFound)
733+ }))
734+ defer redir.Close()
735+ mk := func(u, token, origin string) Resolver {
736+ return func(context.Context, []string) ([]ResolvedAction, error) {
737+ return []ResolvedAction{{Ref: "o/x@v1", NameWithOwner: "o/x", ResolvedSHA: strings.Repeat("d", 40), TarballURL: u, Token: token, AuthOrigin: origin}}, nil
738+ }
739+ }
740+ run := func(r Resolver) Result {
741+ _, res := actionRun(t, files{}, " - uses: o/x@v1\n", func(c *Config) {
742+ c.Actions.Resolver = r
743+ c.Actions.Backoff = func() time.Duration { return time.Millisecond }
744+ })
745+ return res
746+ }
747+ // Redirect to another origin (another port on the same host): the header is
748+ // sent to the first hop and dropped for the second.
749+ if res := run(mk(redir.URL+"/t", "tok", originOf(redir.URL))); res.Conclusion != ConclusionSuccess {
750+ t.Fatalf("%+v", res)
751+ }
752+ if firstAuth.Load() == "" || gotAuth.Load() != "" {
753+ t.Errorf("first hop %q, second hop %q", firstAuth.Load(), gotAuth.Load())
754+ }
755+ // A token without an AuthOrigin, or for another origin, is never sent.
756+ gotAuth.Store("unset")
757+ if res := run(mk(other.URL+"/t", "tok", "")); res.Conclusion != ConclusionSuccess {
758+ t.Fatalf("%+v", res)
759+ }
760+ if gotAuth.Load() != "" {
761+ t.Errorf("no AuthOrigin: sent %q", gotAuth.Load())
762+ }
763+ gotAuth.Store("unset")
764+ _ = run(mk(other.URL+"/t", "tok", "https://other.example"))
765+ if gotAuth.Load() != "" {
766+ t.Errorf("another origin: sent %q", gotAuth.Load())
767+ }
768+ // Without a token (github.com) nothing is sent either.
769+ gotAuth.Store("unset")
770+ _ = run(mk(other.URL+"/t", "", ""))
771+ if gotAuth.Load() != "" {
772+ t.Errorf("anonymous: sent %q", gotAuth.Load())
773+ }
774+ // The named origin, with the token.
775+ gotAuth.Store("unset")
776+ _ = run(mk(other.URL+"/t", "tok", originOf(other.URL)))
777+ if got, _ := gotAuth.Load().(string); !strings.HasPrefix(got, "Basic ") {
778+ t.Errorf("named origin: %q", got)
779+ }
780+}
781+
782+func TestArchiveRedirectPolicy(t *testing.T) {
783+ j := &job{}
784+ c := j.archiveClient()
785+ mkreq := func(u string) *http.Request {
786+ r, _ := http.NewRequest(http.MethodGet, u, nil)
787+ r.Header.Set("Authorization", "Basic x")
788+ return r
789+ }
790+ for _, tc := range []struct {
791+ from, to string
792+ keep bool
793+ refused bool
794+ }{
795+ {"https://a.example/x", "https://a.example/y", true, false},
796+ {"https://a.example/x", "https://cdn.a.example/y", false, false}, // subdomain
797+ {"https://a.example/x", "https://a.example:8443/y", false, false}, // port
798+ {"https://a.example/x", "http://a.example/y", false, true}, // downgrade
799+ {"http://a.example/x", "https://a.example/y", false, false}, // scheme change
800+ {"https://a.example:443/x", "https://a.example/y", true, false}, // same origin
801+ {"https://A.example/x", "https://a.example/y", true, false},
802+ } {
803+ req := mkreq(tc.to)
804+ err := c.CheckRedirect(req, []*http.Request{mkreq(tc.from)})
805+ if (err != nil) != tc.refused || (req.Header.Get("Authorization") != "") != tc.keep && !tc.refused {
806+ t.Errorf("%s -> %s: err=%v header=%q", tc.from, tc.to, err, req.Header.Get("Authorization"))
807+ }
808+ }
809+ if err := c.CheckRedirect(mkreq("https://a.example/"), make([]*http.Request, 10)); err == nil {
810+ t.Error("redirect loops must stop")
811+ }
812+}
813+
814+// GO-008: Go rebuilds the headers of a redirected request from the first
815+// request, so a chain that leaves the origin and stays on the new one, or comes
816+// back, would get the Authorization header again on the third request. Real
817+// servers, real redirects.
818+func TestArchiveRedirectChainNeverReauthorises(t *testing.T) {
819+ type hit struct{ path, auth string }
820+ var mu sync.Mutex
821+ var hits []hit
822+ record := func(r *http.Request) {
823+ mu.Lock()
824+ hits = append(hits, hit{r.URL.Path, r.Header.Get("Authorization")})
825+ mu.Unlock()
826+ }
827+ var a, b *httptest.Server
828+ // a (the authorised origin) and b are two servers on the same host and
829+ // different ports: the case of Go's host-only comparison.
830+ a = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
831+ record(r)
832+ switch r.URL.Path {
833+ case "/start":
834+ http.Redirect(w, r, b.URL+"/first", http.StatusFound)
835+ case "/back":
836+ _, _ = w.Write(tarOf(t, "o-x-1", files{"action.yml": nodeAction, "main.sh": "echo ok\n"}))
837+ }
838+ }))
839+ defer a.Close()
840+ b = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
841+ record(r)
842+ switch r.URL.Path {
843+ case "/first":
844+ http.Redirect(w, r, b.URL+"/second", http.StatusFound)
845+ case "/second":
846+ http.Redirect(w, r, a.URL+"/back", http.StatusFound) // and back to the origin
847+ }
848+ }))
849+ defer b.Close()
850+ res := func(context.Context, []string) ([]ResolvedAction, error) {
851+ return []ResolvedAction{{Ref: "o/x@v1", NameWithOwner: "o/x", ResolvedSHA: strings.Repeat("e", 40), TarballURL: a.URL + "/start", Token: "tok", AuthOrigin: originOf(a.URL)}}, nil
852+ }
853+ _, r := actionRun(t, files{}, " - uses: o/x@v1\n", func(c *Config) { c.Actions.Resolver = res })
854+ if r.Conclusion != ConclusionSuccess {
855+ t.Fatalf("%+v", r)
856+ }
857+ want := []hit{{"/start", "set"}, {"/first", ""}, {"/second", ""}, {"/back", ""}}
858+ mu.Lock()
859+ defer mu.Unlock()
860+ if len(hits) != len(want) {
861+ t.Fatalf("hits %v", hits)
862+ }
863+ for i, h := range hits {
864+ if h.path != want[i].path || (h.auth != "") != (want[i].auth != "") {
865+ t.Errorf("hit %d: %+v, want %+v (auth %q)", i, h, want[i], h.auth)
866+ }
867+ }
868+}
869+
870+// GO-012: the token goes to the exact origin, in canonical form.
871+func TestTokenAllowedOrigins(t *testing.T) {
872+ for _, tc := range []struct {
873+ url, origin string
874+ ok bool
875+ }{
876+ {"https://ermite.example/x", "https://ermite.example", true},
877+ {"https://ermite.example:443/x", "https://ermite.example", true}, // default port, either way
878+ {"https://ermite.example/x", "https://ermite.example:443", true},
879+ {"https://ERMITE.example/x", "https://ermite.example", true},
880+ {"https://[::1]:8443/x", "https://[::1]:8443", true},
881+ {"https://[::1]:8443/x", "https://[::1:8443]", false}, // the collision of the review
882+ {"https://[::1:8443]/x", "https://[::1]:8443", false},
883+ {"https://[::1]/x", "https://[::1]:443", true},
884+ {"https://[::1]/x", "https://[::1]:8443", false},
885+ {"http://ermite.example/x", "https://ermite.example", false},
886+ {"https://ermite.example:8443/x", "https://ermite.example", false},
887+ {"https://sub.ermite.example/x", "https://ermite.example", false},
888+ {"https://ermite.example/x", "", false},
889+ {"https://ermite.example/x", "not an origin", false},
890+ {"https://u:p@ermite.example/x", "https://ermite.example", false},
891+ } {
892+ if got := tokenAllowed(ResolvedAction{Token: "t", TarballURL: tc.url, AuthOrigin: tc.origin}); got != tc.ok {
893+ t.Errorf("url %q origin %q: %v, want %v", tc.url, tc.origin, got, tc.ok)
894+ }
895+ }
896+ if tokenAllowed(ResolvedAction{TarballURL: "https://a.test/x", AuthOrigin: "https://a.test"}) {
897+ t.Error("no token, nothing to allow")
898+ }
899+}
A internal/executor/actions_types.go +169 -0
@@ -0,0 +1,169 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "net/http"
8+ "strconv"
9+ "strings"
10+ "sync/atomic"
11+ "time"
12+
13+ "bnema.dev/ermite-actions/action"
14+ "bnema.dev/ermite-actions/workflow"
15+)
16+
17+// ResolvedAction is where to download one action (ermite.ResolvedAction, see
18+// the host). The Token is a secret: the executor masks it.
19+type ResolvedAction struct {
20+ Ref, NameWithOwner, Path, ResolvedSHA, TarballURL, Token, Error string
21+ // AuthOrigin is the only origin (scheme://host[:port]) the Token is sent to;
22+ // the host sets it. Without it the token is not sent.
23+ AuthOrigin string
24+}
25+
26+// Resolver resolves "owner/repo[/path]@ref" values of uses with the server. It
27+// is the host's call (the job token stays out of the sandbox); the result has
28+// one entry per value, in order.
29+type Resolver func(ctx context.Context, uses []string) ([]ResolvedAction, error)
30+
31+// ActionsConfig is what the executor needs to run actions.
32+type ActionsConfig struct {
33+ // Resolver is required to run actions from repositories; without it a job
34+ // that uses one fails "Set up job".
35+ Resolver Resolver
36+ // HTTPClient downloads the archives; nil means a client with the default
37+ // transport. It must not follow a redirect to another host with the
38+ // credentials (net/http does not).
39+ HTTPClient *http.Client
40+ // ExternalsDir is where Node.js lives (<dir>/node20/bin/node); empty means
41+ // /home/runner/externals.
42+ ExternalsDir string
43+ // DockerPath is the docker CLI; empty means the one found in PATH.
44+ DockerPath string
45+ // Node24ByDefault is the server flag actions.runner.usenode24bydefault
46+ // (HandlerFactory.cs, NodeUtil.DetermineActionsNodeVersion): ermite has no
47+ // feature flags, so the default is pinned here.
48+ Node24ByDefault bool
49+ // Backoff, when set, replaces the random 10 to 30 s wait between download
50+ // attempts (tests).
51+ Backoff func() time.Duration
52+}
53+
54+const defaultExternalsDir = "/home/runner/externals"
55+
56+// stage is ActionRunStage.
57+type stage int
58+
59+const (
60+ stageMain stage = iota
61+ stagePre
62+ stagePost
63+)
64+
65+// compositeMaxDepth is Constants.CompositeActionsMaxDepth
66+// (Runner.Common/Constants.cs:46).
67+const compositeMaxDepth = 9
68+
69+// kind is what an action runs.
70+type kind int
71+
72+const (
73+ kindScript kind = iota
74+ kindNode
75+ kindDocker
76+ kindComposite
77+)
78+
79+// actionDef is a loaded action (ActionManager.LoadAction's Definition).
80+type actionDef struct {
81+ kind kind
82+ // dir is the directory of the action ("" for docker://).
83+ dir string
84+ act *action.Action // nil for docker:// and for a Dockerfile without manifest
85+ // image is the "runs.image" of a container action, or the image of a
86+ // docker:// step; "" for Dockerfile-only actions (see dockerfile).
87+ image string
88+ // dockerfile is the path of the Dockerfile of a Dockerfile-only action.
89+ dockerfile string
90+}
91+
92+func (d *actionDef) runs() *action.Runs {
93+ if d.act == nil {
94+ return nil
95+ }
96+ return d.act.Runs
97+}
98+
99+// preIf and postIf are InitCondition and CleanupCondition: always() unless the
100+// manifest says otherwise (ActionExecutionData, ActionManager.cs:1884-1900).
101+func (d *actionDef) preIf() string { return condOrAlways(d.runs(), true) }
102+func (d *actionDef) postIf() string { return condOrAlways(d.runs(), false) }
103+
104+func condOrAlways(r *action.Runs, pre bool) string {
105+ if r == nil || r.Kind == action.RunsComposite {
106+ return "always()"
107+ }
108+ c := r.PostIf
109+ if pre {
110+ c = r.PreIf
111+ }
112+ if strings.TrimSpace(c) == "" {
113+ return "always()"
114+ }
115+ return c
116+}
117+
118+// node is one step of the job or of a composite action, with what the runner
119+// keeps per action step: its intra-action state, and for a composite its
120+// children and its pre and post steps (CompositeActionExecutionData).
121+type node struct {
122+ st *workflow.Step
123+ uses action.Uses
124+ parent *node
125+ // scope is the name of the steps context of the step's children: the
126+ // fully qualified context name (ExecutionContext.GetFullyQualifiedContextName).
127+ scope string
128+ // stateKey names the intra-action state of the action (job.saveState): the
129+ // pre, main and post stages of an action, and its inner steps, share it
130+ // (the runner keys it by the id of the step, FileCommandManager.cs:305-318).
131+ stateKey string
132+ // children are the nodes of a composite action's steps.
133+ children []*node
134+ // preSteps and postSteps are the embedded steps with a pre or a post, in
135+ // declaration order (the runner's post steps are a stack: they run last
136+ // to first).
137+ preSteps, postSteps []*node
138+ // image is the image the setup steps pulled or built for the step.
139+ image string
140+ def *actionDef
141+ // postRegistered is Root.StepsWithPostRegistered / EmbeddedStepsWithPostRegistered.
142+ postRegistered bool
143+ postCond string
144+}
145+
146+// nodeSeq numbers the nodes, for their state keys.
147+var nodeSeq atomic.Int64
148+
149+// newNodes makes the nodes of steps. The workflow reader has checked every
150+// uses value.
151+func newNodes(steps []*workflow.Step, parent *node, scope string) ([]*node, error) {
152+ out := make([]*node, 0, len(steps))
153+ for _, st := range steps {
154+ n := &node{st: st, parent: parent, stateKey: "node-" + strconv.FormatInt(nodeSeq.Add(1), 10)}
155+ n.scope = st.ID
156+ if scope != "" {
157+ n.scope = scope + "." + st.ID
158+ }
159+ if st.Uses != nil {
160+ u, err := action.ParseUses(st.Uses.Value)
161+ if err != nil {
162+ return nil, err
163+ }
164+ n.uses = u
165+ }
166+ out = append(out, n)
167+ }
168+ return out, nil
169+}
M internal/executor/contexts.go +28 -12
@@ -33,8 +33,15 @@ func (o *ordered) Set(k, v string) {
3333 }
3434
3535 func (o *ordered) Get(k string) (string, bool) { v, ok := o.vals[k]; return v, ok }
36-func (o *ordered) Len() int { return len(o.keys) }
37-func (o *ordered) Keys() []string { return slices.Clone(o.keys) }
36+func (o *ordered) Remove(k string) {
37+ if _, ok := o.vals[k]; !ok {
38+ return
39+ }
40+ delete(o.vals, k)
41+ o.keys = slices.DeleteFunc(o.keys, func(x string) bool { return x == k })
42+}
43+func (o *ordered) Len() int { return len(o.keys) }
44+func (o *ordered) Keys() []string { return slices.Clone(o.keys) }
3845
3946 func (o *ordered) Clone() *ordered {
4047 c := newOrdered()
@@ -130,6 +137,8 @@ type Layout struct {
130137 RunnerWorkspace string
131138 // Workspace is GITHUB_WORKSPACE: <WorkRoot>/<repo>/<repo>.
132139 Workspace string
140+ // Actions is where the actions of the job are downloaded: <WorkRoot>/_actions.
141+ Actions string
133142 }
134143
135144 // DefaultToolCache is RUNNER_TOOL_CACHE on the ermite image.
@@ -145,6 +154,7 @@ func NewLayout(home, repo, toolCache string) Layout {
145154 return Layout{
146155 Home: home, WorkRoot: work, Temp: filepath.Join(work, "_temp"), ToolCache: toolCache,
147156 RunnerWorkspace: filepath.Join(work, repo), Workspace: filepath.Join(work, repo, repo),
157+ Actions: filepath.Join(work, "_actions"),
148158 }
149159 }
150160
@@ -210,9 +220,10 @@ type State struct {
210220 // env is Global.EnvironmentVariables: the job-level environment and what
211221 // GITHUB_ENV adds. A step's own env is not in it.
212222 env *ordered
213- // steps is the steps context, in order of first mention.
214- stepOrder []string
215- steps map[string]*stepCtx
223+ // root is the steps context of the job; cur is the one in use: a composite
224+ // action's steps see their own (see PushScope).
225+ root, cur *stepsScope
226+ scopes map[string]*stepsScope
216227 // prepend is Global.PrependPath in the order of the GITHUB_PATH entries.
217228 prepend []string
218229 // status is job.status: "success", "failure" or "cancelled".
@@ -239,7 +250,9 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac
239250 }
240251 root = d
241252 }
242- s := &State{other: map[string]expr.Value{}, env: newOrdered(), steps: map[string]*stepCtx{}, status: "success"}
253+ s := &State{other: map[string]expr.Value{}, env: newOrdered(), status: "success", scopes: map[string]*stepsScope{}}
254+ s.root = newScope()
255+ s.cur = s.root
243256
244257 // github: token first, then what the server gave (ExecutionContext.cs:1023-1037).
245258 // The token is the one the server sent in github.token (GitHub's
@@ -404,11 +417,11 @@ func (s *State) Status() string { return s.status }
404417 func stepIDVisible(id string) bool { return id != "" && !strings.HasPrefix(id, "__") }
405418
406419 func (s *State) step(id string) *stepCtx {
407- c, ok := s.steps[id]
420+ c, ok := s.cur.steps[id]
408421 if !ok {
409422 c = &stepCtx{outputs: newOrdered()}
410- s.steps[id] = c
411- s.stepOrder = append(s.stepOrder, id)
423+ s.cur.steps[id] = c
424+ s.cur.order = append(s.cur.order, id)
412425 }
413426 return c
414427 }
@@ -434,7 +447,7 @@ func (s *State) SetStepResult(id, outcome, conclusion string) {
434447
435448 // Output returns an output of a step ("" when absent).
436449 func (s *State) Output(id, name string) string {
437- if c, ok := s.steps[id]; ok {
450+ if c, ok := s.cur.steps[id]; ok {
438451 v, _ := c.outputs.Get(name)
439452 return v
440453 }
@@ -443,8 +456,8 @@ func (s *State) Output(id, name string) string {
443456
444457 func (s *State) stepsValue() *expr.Dictionary {
445458 d := expr.NewDictionary()
446- for _, id := range s.stepOrder {
447- c := s.steps[id]
459+ for _, id := range s.cur.order {
460+ c := s.cur.steps[id]
448461 sd := expr.NewDictionary()
449462 sd.Add("outputs", c.outputs.Dict())
450463 if c.outcome != "" {
@@ -474,6 +487,9 @@ func (s *State) Values(env *ordered) map[string]expr.Value {
474487 for k, v := range s.other {
475488 m[k] = v
476489 }
490+ if s.cur.inputs != nil {
491+ m["inputs"] = s.cur.inputs
492+ }
477493 return m
478494 }
479495
M internal/executor/exec_step.go +37 -20
@@ -6,20 +6,13 @@ import (
66 "fmt"
77 "path/filepath"
88 "strings"
9-
10- "bnema.dev/ermite-actions/workflow"
119 )
1210
1311 // execute is ActionRunner.RunAsync plus ScriptHandler for one run step.
1412 func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
1513 num, st := s.num, s.st
16- if msg := notSupported(st); msg != "" {
17- if st.Uses != nil {
18- j.out(num, "##[group]Run "+st.Uses.Value)
19- j.out(num, "##[endgroup]")
20- }
21- j.errorf(num, "%s", msg)
22- return resFailed
14+ if st.Uses != nil {
15+ return j.runAction(s, stepCancel)
2316 }
2417 fail := func(err error) stepResult { j.errorf(num, "%s", err.Error()); return resFailed }
2518 failTemplate := func(err error) stepResult {
@@ -27,11 +20,11 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult {
2720 return resFailed
2821 }
2922
30- if err := j.prepareFileCommands(st); err != nil {
23+ if err := j.prepareFileCommands(s); err != nil {
3124 return fail(err)
3225 }
3326 // ProcessFiles runs after the handler, even when it failed (a finally block).
34- defer j.fc.Process(s)
27+ defer s.fc.Process(s)
3528
3629 in, err := j.scriptInputs(s)
3730 if err != nil {
@@ -67,7 +60,10 @@ func (j *job) scriptInputs(s *stepRun) (scriptIn, error) {
6760 if in.shell, err = j.evalString(st.Shell, s.env); err != nil {
6861 return in, err
6962 }
70- if in.shell == "" {
63+ // defaults.run applies to the steps of the job only, not to the ones of a
64+ // composite action (ScriptHandler.cs:158-181: ScopeName is empty).
65+ top := s.parent == nil
66+ if in.shell == "" && top {
7167 in.shell = j.runDefaults.shell
7268 }
7369 if st.WorkingDirectory != nil {
@@ -76,7 +72,7 @@ func (j *job) scriptInputs(s *stepRun) (scriptIn, error) {
7672 return in, err
7773 }
7874 }
79- if !in.hasWorkingDirectory {
75+ if !in.hasWorkingDirectory && top {
8076 in.workingDirectory = j.runDefaults.workingDirectory
8177 }
8278 return in, nil
@@ -85,18 +81,25 @@ func (j *job) scriptInputs(s *stepRun) (scriptIn, error) {
8581 // prepareFileCommands creates the files of the step, puts their paths in the
8682 // github context (FileCommandManager.InitializeFiles) and writes event.json
8783 // (ExecutionContext.WriteWebhookPayload).
88-func (j *job) prepareFileCommands(st *workflow.Step) error {
84+func (j *job) prepareFileCommands(s *stepRun) error {
8985 j.mu.Lock()
9086 defer j.mu.Unlock()
91- files, err := j.fc.Init()
87+ if s.fc == nil {
88+ s.fc = j.fc
89+ }
90+ files, err := s.fc.Init()
9291 if err != nil {
9392 return err
9493 }
9594 for _, c := range fileCommands {
9695 j.state.SetGitHub(c.ContextName, files[envVarName(c.ContextName)])
9796 }
98- j.state.SetGitHub("action_repository", "")
99- j.state.SetGitHub("action_ref", "")
97+ if s.n == nil || s.n.st.Uses == nil {
98+ // ActionRunner.cs:149-165 sets them for every action step, a run step
99+ // included (its reference is a script, so they are empty).
100+ j.state.SetGitHub("action_repository", "")
101+ j.state.SetGitHub("action_ref", "")
102+ }
100103 if ev := j.state.EventJSON(); ev != "" {
101104 if p, err := j.writeEvent(ev); err == nil {
102105 j.state.SetGitHub("event_path", p)
@@ -191,17 +194,26 @@ func (j *job) processEnv(stepEnv *ordered) []string {
191194 // startProcess runs the script and turns how it ended into the result of the
192195 // step.
193196 func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string, stepCancel <-chan struct{}) stepResult {
197+ return j.runProc(s, plan.CommandPath, dir, argv, "", stepCancel)
198+}
199+
200+// runProc starts a process (exitDebug, when set, is the format of the debug line that
201+// replaces the error about the exit code) for the step and streams its output through the
202+// commands of the step. A script reports its exit code (ScriptHandler.cs:
203+// 267-275); the handlers of actions only mark the step failed (NodeScriptAction
204+// Handler.cs:178-184), a failing action says why itself.
205+func (j *job) runProc(s *stepRun, shown, dir string, argv []string, exitDebug string, stepCancel <-chan struct{}) stepResult {
194206 num := s.num
195207 om := newOutputManager(s, stepCancel)
196208 defer om.dispose()
197209 res, err := runProcess(procSpec{
198- Argv: argv, Dir: dir, Env: j.processEnv(s.env), Credential: j.cfg.Credential,
210+ Argv: argv, Dir: dir, Env: s.procEnv(), Credential: j.cfg.Credential,
199211 OnLine: om.onData,
200212 Cancel: stepCancel, TermAfter: j.cfg.TermAfter, KillAfter: j.cfg.KillAfter,
201213 WaitDelay: j.cfg.WaitDelay, Signal: j.cfg.Signal,
202214 })
203215 if err != nil {
204- j.errorf(num, "%s", startError(plan.CommandPath, dir, err))
216+ j.errorf(num, "%s", startError(shown, dir, err))
205217 return resFailed
206218 }
207219 if res.Cancelled {
@@ -211,8 +223,13 @@ func (j *job) startProcess(s *stepRun, plan ShellPlan, dir string, argv []string
211223 s.logCanceled()
212224 return resCanceled
213225 }
226+ if exitDebug != "" {
227+ j.debug(num, fmt.Sprintf(exitDebug, res.ExitCode))
228+ }
214229 if res.ExitCode != 0 {
215- j.errorf(num, "Process completed with exit code %d.", res.ExitCode)
230+ if exitDebug == "" {
231+ j.errorf(num, "Process completed with exit code %d.", res.ExitCode)
232+ }
216233 return resFailed
217234 }
218235 return resSucceeded
A internal/executor/filecmd_fork.go +22 -0
@@ -0,0 +1,22 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+// Fork returns file commands that share the directory and the root of f with
6+// a suffix of their own: every action step has its own manager in the runner
7+// (HostContext.CreateService<IFileCommandManager>(), ActionRunner.cs:173), so
8+// the steps of a composite action do not delete the files of the step around
9+// them. The fork must not be closed.
10+func (f *FileCommands) Fork() *FileCommands {
11+ return &FileCommands{dir: f.dir, root: f.root, owner: f.owner}
12+}
13+
14+// Cleanup removes the files of the current step of a fork.
15+func (f *FileCommands) Cleanup() {
16+ if f.suffix == "" {
17+ return
18+ }
19+ for _, c := range fileCommands {
20+ _ = f.root.Remove(f.name(c, f.suffix))
21+ }
22+}
M internal/executor/job.go +56 -6
@@ -8,6 +8,7 @@ import (
88 "fmt"
99 "os"
1010 "path/filepath"
11+ "slices"
1112 "strings"
1213 "sync"
1314 "sync/atomic"
@@ -75,6 +76,9 @@ type Config struct {
7576 BaseEnv []string
7677 // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG).
7778 Debug bool
79+ // Actions configures the actions of the job (resolution, downloads, Node.js,
80+ // docker).
81+ Actions ActionsConfig
7882 // SendAnnotations and SendSummaries say that the server keeps annotations
7983 // and step summaries (features ci.annotations and ci.summaries): the
8084 // Reporter then gets them with StepResults.
@@ -158,6 +162,13 @@ type job struct {
158162 // masker hides the secrets in everything reported; it is set once the
159163 // contexts are built, before any step runs.
160164 masker atomic.Pointer[masker]
165+ // actions holds what the pre-job phase loaded (see prepareActions).
166+ actions *actionState
167+ // postStack is the post steps registered so far, last registered last.
168+ postStack []qstep
169+ // upgradedToNode24 are the actions that asked for node20 and got node24.
170+ upgradedToNode24 []string
171+ jobCtx context.Context
161172
162173 // Results of the commands (see issues.go, matchers.go, results.go).
163174 matchers *matcherRoot
@@ -210,6 +221,16 @@ func (j *job) run() Result {
210221 // ---- Set up job (JobExtension.InitializeJob) ----
211222 rep := j.cfg.Reporter
212223 rep.StepStarted(1, SetUpJobName, j.now())
224+ var cancelCtx context.CancelFunc
225+ j.jobCtx, cancelCtx = context.WithCancel(context.Background())
226+ defer cancelCtx()
227+ go func() {
228+ select {
229+ case <-j.cancel.Done():
230+ cancelCtx()
231+ case <-j.jobCtx.Done():
232+ }
233+ }()
213234 steps, err := j.setUp()
214235 if err != nil {
215236 j.errorf(1, "%s", err.Error())
@@ -220,13 +241,29 @@ func (j *job) run() Result {
220241 rep.StepCompleted(1, SetUpJobName, ConclusionSuccess, j.now())
221242
222243 // ---- the steps (StepsRunner) ----
223- for i, st := range steps {
224- j.runStep(i+2, st)
244+ // The queue is the pre-job steps then the steps of the job; the post steps
245+ // registered while they run follow, last registered first
246+ // (StepsRunner.cs:62-75).
247+ num := 2
248+ for _, q := range steps {
249+ j.runStep(num, q)
250+ num++
251+ }
252+ for {
253+ j.mu.Lock()
254+ if len(j.postStack) == 0 {
255+ j.mu.Unlock()
256+ break
257+ }
258+ q := j.postStack[len(j.postStack)-1]
259+ j.postStack = j.postStack[:len(j.postStack)-1]
260+ j.mu.Unlock()
261+ j.runStep(num, q)
262+ num++
225263 }
226264
227265 // ---- Complete job (JobExtension.FinalizeJob) ----
228- last := len(steps) + 2
229- outputs := j.completeJob(last)
266+ outputs := j.completeJob(num)
230267
231268 return j.finish(outputs)
232269 }
@@ -345,7 +382,7 @@ func (j *job) evalMap(m *template.Mapping, env *ordered) ([]Pair, error) {
345382 // setUp parses the workflow and prepares the job: JobExtension.InitializeJob
346383 // (JobExtension.cs:63-182) and the job-start code of JobRunner.cs:142-177. Its
347384 // log lines belong to step 1.
348-func (j *job) setUp() ([]*workflow.Step, error) {
385+func (j *job) setUp() ([]qstep, error) {
349386 cfg := j.cfg
350387 j.out(1, fmt.Sprintf("Current runner version: '%s'", cfg.RunnerVersion))
351388 // Only a self-hosted runner prints its name and machine (JobExtension.cs:
@@ -472,6 +509,10 @@ func (j *job) setUp() ([]*workflow.Step, error) {
472509 }
473510
474511 j.out(1, "Prepare all required actions")
512+ queue, err := j.prepareJob()
513+ if err != nil {
514+ return nil, err
515+ }
475516 // JobExtension.cs:418-422: the display name of the job, which the server
476517 // computes there (message.JobDisplayName). ermite's Job message has no such
477518 // field, so the executor evaluates it (see jobDisplayName).
@@ -488,7 +529,7 @@ func (j *job) setUp() ([]*workflow.Step, error) {
488529 for _, p := range snapshotProcesses() {
489530 j.before[p.key()] = true
490531 }
491- return jb.Steps, nil
532+ return queue, nil
492533 }
493534
494535 // own gives a file or directory created by the executor to the user the steps
@@ -590,6 +631,15 @@ func (j *job) completeJob(num int) map[string]string {
590631 j.out(num, "Cleaning up orphan processes")
591632 killOrphans(j.trackID, j.before, func(l string) { j.out(num, l) })
592633
634+ // The actions that asked for node20 and got node24 (JobExtension.cs:1043-1049).
635+ j.mu.Lock()
636+ up := slices.SortedFunc(slices.Values(j.upgradedToNode24), func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) })
637+ j.mu.Unlock()
638+ if len(up) > 0 {
639+ j.tagged(num, "##[warning]", "Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: "+
640+ strings.Join(up, ", ")+". For more information see: "+node20DeprecationURL)
641+ }
642+
593643 rep.StepCompleted(num, CompleteJobName, result.conclusion(), j.now())
594644 return outputs
595645 }
M internal/executor/job_test.go +12 -17
@@ -194,23 +194,6 @@ jobs:
194194
195195 func TestNotSupportedYet(t *testing.T) {
196196 needTools(t, "bash")
197- rec, res := runLocal(t, wfHead+` steps:
198- - uses: actions/checkout@v4
199- - name: after
200- if: always()
201- run: echo still-runs
202- - run: echo skipped
203-`, "j", nil)
204- if res.Conclusion != ConclusionFailure {
205- t.Fatalf("%+v", res)
206- }
207- u := rec.step(t, "Run actions/checkout@v4")
208- if u.Conclusion != ConclusionFailure || !strings.Contains(logOf(u), "##[error]not supported yet: uses (actions/checkout@v4)") {
209- t.Fatalf("%+v", u)
210- }
211- if rec.step(t, "after").Conclusion != ConclusionSuccess {
212- t.Error("always() steps still run after a failure")
213- }
214197 for _, f := range []string{"container: alpine", "services:\n s:\n image: x"} {
215198 rec2, res2 := runLocal(t, wfHead+" "+f+"\n steps:\n - run: echo hi\n", "j", nil)
216199 if res2.Conclusion != ConclusionFailure || res2.Error == "" || !strings.Contains(logOf(rec2.steps[0]), "not supported yet") {
@@ -219,6 +202,18 @@ func TestNotSupportedYet(t *testing.T) {
219202 }
220203 }
221204
205+// Without a resolver (a host without the feature) an action fails "Set up job"
206+// before any step, with the runner's message.
207+func TestActionWithoutResolverFailsSetUp(t *testing.T) {
208+ rec, res := runLocal(t, wfHead+" steps:\n - uses: actions/checkout@v4\n - run: echo NO\n", "j", nil)
209+ if res.Conclusion != ConclusionFailure || len(rec.steps) != 1 {
210+ t.Fatalf("%+v %v", res, rec.names())
211+ }
212+ if got := logOf(rec.steps[0]); !strings.Contains(got, "##[error]Failed to resolve action download info.") {
213+ t.Fatalf("%s", got)
214+ }
215+}
216+
222217 func TestInvalidWorkflowAndUnknownJob(t *testing.T) {
223218 rec, res := runLocal(t, "on: push\njobs: [", "j", nil)
224219 if res.Conclusion != ConclusionFailure || !strings.Contains(res.Error, "The workflow is not valid") || len(rec.steps) != 1 || !rec.done {
M internal/executor/step.go +56 -172
@@ -5,10 +5,10 @@ package executor
55 import (
66 "context"
77 "errors"
8- "fmt"
98 "strings"
10- "sync"
9+ "time"
1110
11+ "bnema.dev/ermite-actions/action"
1212 "bnema.dev/ermite-actions/expr"
1313 "bnema.dev/ermite-actions/template"
1414 "bnema.dev/ermite-actions/workflow"
@@ -17,21 +17,64 @@ import (
1717 // ---------------------------------------------------------------- steps
1818
1919 // stepRun is the state of one step being run; it implements Sink and
20-// commandHost.
20+// commandHost. An embedded step (inside a composite action) is a stepRun too,
21+// with a parent and the number of the outer step: its log lines belong to it.
2122 type stepRun struct {
2223 j *job
2324 num int
2425 st *workflow.Step
26+ n *node
27+ stage stage
28+ parent *stepRun
2529 env *ordered // the step's environment: job env + step env
30+ overrides *ordered // the step env of this step and of its composite parents
2631 cmdResult *stepResult
2732 outcome *stepResult
2833 cmds *commandManager
34+ // result is ExecutionContext.Result of a step that runs embedded steps: it
35+ // gives action_status to them.
36+ result *stepResult
37+ // fc is the file commands of the step: the job's for a step of the job, a
38+ // fork for an embedded one.
39+ fc *FileCommands
40+ // actEnv, when set, is the environment of the process of an action step
41+ // (env context, STATE_ and INPUT_); rawEnvList replaces the whole
42+ // environment of the process (docker, whose -e options read it).
43+ actEnv *ordered
44+ rawEnvList []string
45+ // def is the action the step runs.
46+ def *actionDef
47+ nodeVersion string
2948 // scope is what the step shares with the step that contains it, see
30- // stepScope. name is the display name; timedOut is set when the step
49+ // stepScope. name is the display name of the step in the log; timedOut is set when the step
3150 // reached its timeout-minutes.
3251 scope stepScope
3352 name string
3453 timedOut timedOutFlag
54+ // deadline is when the timeout-minutes of the step, or of the step around it,
55+ // ends: zero for none. An inner step of a composite action inherits what is
56+ // left of it (ExecutionContext.CreateEmbeddedChild, :498).
57+ deadline time.Time
58+}
59+
60+// depth is the number of composite actions around the step.
61+func (s *stepRun) depth() int {
62+ d := 0
63+ for p := s.parent; p != nil; p = p.parent {
64+ d++
65+ }
66+ return d
67+}
68+
69+// procEnv is the environment of the process the step starts.
70+func (s *stepRun) procEnv() []string {
71+ if s.rawEnvList != nil {
72+ return s.rawEnvList
73+ }
74+ if s.actEnv != nil {
75+ return s.j.processEnv(s.actEnv)
76+ }
77+ return s.j.processEnv(s.env)
3578 }
3679
3780 func (s *stepRun) Out(text string) { s.j.out(s.num, text) }
@@ -86,6 +129,15 @@ func (j *job) displayName(st *workflow.Step, env *ordered) string {
86129 case st.Run != nil:
87130 tok, prefix = st.Run, "Run "
88131 case st.Uses != nil:
132+ // The name is built from the parsed reference (ActionRunner.cs:305-333):
133+ // the image of a docker:// step without its scheme, "Name[/Path]@Ref" for
134+ // a repository, the path for a local action.
135+ if u, err := action.ParseUses(st.Uses.Value); err == nil {
136+ if u.Kind == action.UsesDocker {
137+ return formatStepName("Run ", u.Image)
138+ }
139+ return formatStepName("Run ", refString(u))
140+ }
89141 tok, prefix = st.Uses, "Run "
90142 default:
91143 return ""
@@ -206,165 +258,6 @@ func formatStepName(prefix, name string) string {
206258 return prefix + firstLine(name)
207259 }
208260
209-func (j *job) statusFuncs() workflow.StatusFuncs {
210- st := j.state.Status()
211- return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"}
212-}
213-
214-// markCancelled records the cancellation of the job: job result Canceled and
215-// job.status cancelled (StepsRunner.cs:152-155 and :190-196).
216-func (j *job) markCancelledLocked() {
217- if j.cancelled {
218- return
219- }
220- j.cancelled = true
221- c := resCanceled
222- j.result = &c
223- j.state.SetStatus("cancelled")
224-}
225-
226-func (j *job) runStep(num int, st *workflow.Step) {
227- rep := j.cfg.Reporter
228- s := &stepRun{j: j, num: num, st: st}
229- s.cmds = newCommandManager(s, j.cfg.Debug)
230-
231- // Environment of the step (StepsRunner.cs:108-139): job env, then the step's
232- // own, evaluated against the job env.
233- j.mu.Lock()
234- // github.action is the id of the step, set before the step's env is
235- // evaluated (StepsRunner.cs:118), so that the env and the if of a step see
236- // its own id (the dump step of the recordings reads __run_6, not __run_5).
237- j.state.SetGitHub("action", st.ID)
238- jobEnv := j.state.JobEnv()
239- j.mu.Unlock()
240- s.env = jobEnv.Clone()
241- pairs, envErr := j.evalMap(st.Env, jobEnv)
242- if envErr == nil {
243- for _, p := range pairs {
244- s.env.Set(p.Key, p.Value)
245- }
246- }
247- name := j.displayName(st, s.env)
248- s.name = name
249- rep.StepStarted(num, name, j.now())
250-
251- complete := func(r stepResult) {
252- res := r
253- j.mu.Lock()
254- // Update the steps context (ExecutionContext.UpdateGlobalStepsContext).
255- outcome := res
256- if s.outcome != nil {
257- outcome = *s.outcome
258- }
259- j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion()))
260- j.recordOutcome(num, outcome.conclusion())
261- if res == resFailed {
262- m := merge(j.result, resFailed)
263- j.result = &m
264- j.state.SetStatus(statusName(m))
265- }
266- j.mu.Unlock()
267- rep.StepCompleted(num, name, res.conclusion(), j.now())
268- }
269-
270- if envErr != nil {
271- j.errorf(num, "%s", templateNotValid(envErr, true))
272- complete(resFailed)
273- return
274- }
275-
276- // Cancellation seen before the step starts (StepsRunner.cs:188-197).
277- cancelledAtStart := false
278- select {
279- case <-j.cancel.Done():
280- cancelledAtStart = true
281- j.mu.Lock()
282- j.markCancelledLocked()
283- j.mu.Unlock()
284- default:
285- }
286-
287- // Condition (StepsRunner.cs:200-261). On runner shutdown conditions are not
288- // evaluated and the step is skipped.
289- cond := func() (bool, error) {
290- j.mu.Lock()
291- defer j.mu.Unlock()
292- return st.EvaluateIf(context.Background(), j.evalEnv(s.env), j.statusFuncs())
293- }
294- if cancelledAtStart && j.cancel.Reason() == CancelShutdown {
295- complete(resSkipped)
296- return
297- }
298- ok, err := cond()
299- switch {
300- case err != nil:
301- j.errorf(num, "%s", templateNotValid(err, false))
302- complete(resFailed)
303- return
304- case !ok:
305- complete(resSkipped)
306- return
307- }
308-
309- // Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes
310- // cancelled and the step is stopped unless its condition still holds
311- // (always()).
312- stepCancel := make(chan struct{})
313- var cancelOnce sync.Once
314- closeCancel := func() { cancelOnce.Do(func() { close(stepCancel) }) }
315- stopWatch := make(chan struct{})
316- var watch sync.WaitGroup
317- if !cancelledAtStart {
318- watch.Add(1)
319- go func() {
320- defer watch.Done()
321- select {
322- case <-j.cancel.Done():
323- case <-stopWatch:
324- return
325- }
326- j.mu.Lock()
327- j.markCancelledLocked()
328- j.mu.Unlock()
329- keep := false
330- if j.cancel.Reason() != CancelShutdown {
331- keep, _ = cond()
332- }
333- if !keep {
334- closeCancel()
335- }
336- }()
337- }
338-
339- stopTimeout, minutes := j.armStepTimeout(s, closeCancel)
340- defer stopTimeout()
341-
342- r := j.execute(s, stepCancel)
343- close(stopWatch)
344- watch.Wait()
345- r = j.stepTimedOut(s, r, minutes)
346-
347- // Merge the result of the commands (StepsRunner.cs:347-351).
348- if s.cmdResult != nil {
349- m := merge(&r, *s.cmdResult)
350- r = m
351- }
352- // continue-on-error (ExecutionContext.ApplyContinueOnError, :1422).
353- if r == resFailed && st.ContinueOnError != nil {
354- coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env))
355- if cerr != nil {
356- j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.")
357- j.errorf(num, "%s", cerr.Error())
358- }
359- if coe && cerr == nil {
360- o := resFailed
361- s.outcome = &o
362- r = resSucceeded
363- }
364- }
365- complete(r)
366-}
367-
368261 // statusName is the job.status for a result (TaskResult.ToActionResult).
369262 func statusName(r stepResult) string {
370263 switch r {
@@ -375,12 +268,3 @@ func statusName(r stepResult) string {
375268 }
376269 return "success"
377270 }
378-
379-// notSupported is the failure of a step that uses a feature of a later phase.
380-func notSupported(st *workflow.Step) string {
381- switch {
382- case st.Uses != nil:
383- return fmt.Sprintf("not supported yet: uses (%s)", st.Uses.Value)
384- }
385- return ""
386-}
A internal/executor/step_run.go +354 -0
@@ -0,0 +1,354 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "context"
7+ "sync"
8+ "time"
9+
10+ "bnema.dev/ermite-actions/action"
11+ "bnema.dev/ermite-actions/expr"
12+ "bnema.dev/ermite-actions/template"
13+ "bnema.dev/ermite-actions/workflow"
14+)
15+
16+// qstep is a step of the job's queue (StepsRunner's JobSteps): a main step, the
17+// pre or post stage of an action, or a setup step (pull or build an image).
18+type qstep struct {
19+ n *node
20+ stage stage
21+ name string
22+ // cond, for a pre or post stage, replaces the step's own condition (the
23+ // action's pre-if or post-if); setup steps use "success()".
24+ cond string
25+ // setup, when set, is the whole step: it runs instead of the node.
26+ setup func(s *stepRun, cancel <-chan struct{}) stepResult
27+}
28+
29+func (j *job) statusFuncs() workflow.StatusFuncs {
30+ st := j.state.Status()
31+ return workflow.StatusFuncs{Success: st == "success", Failure: st == "failure", Cancelled: st == "cancelled"}
32+}
33+
34+// actionStatus is github.action_status of the steps of a composite action: the
35+// result of the action so far (CompositeActionHandler.cs:246), the job's
36+// cancellation included (:340). j.mu is held.
37+func (s *stepRun) actionStatus() string {
38+ if s.j.cancelled {
39+ return "cancelled"
40+ }
41+ if s.result != nil {
42+ return statusName(*s.result)
43+ }
44+ return "success"
45+}
46+
47+// statusFuncs are the status functions of the step: success() and failure()
48+// of an embedded main step read action_status, the others the job's status
49+// (SuccessFunction.cs:28-45). j.mu is held.
50+func (s *stepRun) statusFuncs() workflow.StatusFuncs {
51+ if s.parent != nil && s.stage == stageMain {
52+ a := s.parent.actionStatus()
53+ return workflow.StatusFuncs{Success: a == "success", Failure: a == "failure", Cancelled: s.j.state.Status() == "cancelled"}
54+ }
55+ return s.j.statusFuncs()
56+}
57+
58+// markCancelled records the cancellation of the job: job result Canceled and
59+// job.status cancelled (StepsRunner.cs:152-155 and :190-196).
60+func (j *job) markCancelledLocked() {
61+ if j.cancelled {
62+ return
63+ }
64+ j.cancelled = true
65+ c := resCanceled
66+ j.result = &c
67+ j.state.SetStatus("cancelled")
68+}
69+
70+// evalCondString evaluates the pre-if or post-if of an action (an expression
71+// without ${{ }}, evaluated as written, EvaluateStepIf on a bare condition).
72+func (j *job) evalCondString(cond string, env *ordered, st workflow.StatusFuncs) (bool, error) {
73+ sch, err := action.Schema()
74+ if err != nil {
75+ return false, err
76+ }
77+ tok := template.NewExpression(template.Origin{}, cond, sch.Info("step-if").WithContexts("vars", "needs"))
78+ te := j.evalEnv(env)
79+ funcs := map[string]expr.Func{}
80+ for k, f := range te.Funcs {
81+ funcs[k] = f
82+ }
83+ for k, f := range st.Funcs() {
84+ funcs[k] = f
85+ }
86+ te.Funcs = funcs
87+ v, err := template.EvaluateExpression(context.Background(), tok, te)
88+ if err != nil {
89+ return false, err
90+ }
91+ return expr.Truthy(v), nil
92+}
93+
94+// stepEnvironment builds the environment of a step (StepsRunner.cs:108-139 and
95+// CompositeActionHandler.cs:233-262): the job's, plus for an embedded step the
96+// env of the steps around it, then its own env, evaluated against that.
97+func (j *job) stepEnvironment(s *stepRun) (*ordered, error) {
98+ j.mu.Lock()
99+ env := j.state.JobEnv()
100+ j.mu.Unlock()
101+ s.overrides = newOrdered()
102+ if p := s.parent; p != nil {
103+ for _, k := range p.overrides.Keys() {
104+ v, _ := p.env.Get(k)
105+ env.Set(k, v)
106+ s.overrides.Set(k, v)
107+ }
108+ }
109+ pairs, err := j.evalStepEnv(s.st, env)
110+ if err != nil {
111+ return env, err
112+ }
113+ for _, p := range pairs {
114+ env.Set(p.Key, p.Value)
115+ s.overrides.Set(p.Key, p.Value)
116+ }
117+ return env, nil
118+}
119+
120+// evalStepEnv evaluates the env of a step: a mapping, or the expression that
121+// stands for one (a step of a composite action may write env: ${{ ... }}).
122+func (j *job) evalStepEnv(st *workflow.Step, env *ordered) ([]Pair, error) {
123+ if st.Env != nil || st.EnvExpression == nil {
124+ return j.evalMap(st.Env, env)
125+ }
126+ v, err := template.Evaluate(context.Background(), st.EnvExpression, j.evalEnv(env))
127+ if err != nil {
128+ return nil, err
129+ }
130+ return pairsOf(v)
131+}
132+
133+func pairsOf(v expr.Value) ([]Pair, error) {
134+ d, ok := v.(*expr.Dictionary)
135+ if !ok {
136+ return nil, nil
137+ }
138+ var out []Pair
139+ for _, p := range d.Pairs() {
140+ if k := p.Value.Kind(); k == expr.KindArray || k == expr.KindDictionary {
141+ return nil, ghErrorf("Unexpected type '%s' encountered while reading 'environment value'. The type 'StringToken' was expected.", k)
142+ }
143+ out = append(out, Pair{p.Key, expr.CoerceString(p.Value)})
144+ }
145+ return out, nil
146+}
147+
148+// runStep runs one step of the queue as a step of the job (its own number, its
149+// own record in the log).
150+func (j *job) runStep(num int, q qstep) {
151+ rep := j.cfg.Reporter
152+ s := &stepRun{j: j, num: num, stage: q.stage}
153+ if q.n != nil {
154+ s.st, s.n = q.n.st, q.n
155+ // The state saved by the action is the one of its node, whatever the stage.
156+ s.scope.stateKey = q.n.stateKey
157+ }
158+ s.cmds = newCommandManager(s, j.cfg.Debug)
159+ name := q.name
160+ if q.setup != nil {
161+ s.name = name
162+ rep.StepStarted(num, name, j.now())
163+ r := j.runGuarded(s, q, true)
164+ rep.StepCompleted(num, name, r.conclusion(), j.now())
165+ return
166+ }
167+ if q.n == nil {
168+ return
169+ }
170+ // github.action is the id of the step, set before the step's env is
171+ // evaluated (StepsRunner.cs:118), so that the env and the if of a step see
172+ // its own id (the dump step of the recordings reads __run_6, not __run_5).
173+ j.mu.Lock()
174+ j.state.SetGitHub("action", q.n.st.ID)
175+ j.mu.Unlock()
176+ env, envErr := j.stepEnvironment(s)
177+ s.env = env
178+ if name == "" {
179+ name = j.displayName(q.n.st, s.env)
180+ if q.stage == stagePre {
181+ name = "Pre " + name
182+ }
183+ }
184+ s.name = name
185+ rep.StepStarted(num, name, j.now())
186+ r := j.runGuarded(s, q, envErr == nil)
187+ if envErr != nil {
188+ j.errorf(num, "%s", templateNotValid(envErr, true))
189+ r = resFailed
190+ j.finishStep(s, r)
191+ }
192+ rep.StepCompleted(num, name, r.conclusion(), j.now())
193+}
194+
195+// finishStep records the result of the step in the contexts
196+// (ExecutionContext.UpdateGlobalStepsContext) and, for a step of the job, in
197+// the result of the job.
198+func (j *job) finishStep(s *stepRun, res stepResult) {
199+ j.mu.Lock()
200+ defer j.mu.Unlock()
201+ st := s.st
202+ outcome := res
203+ if s.outcome != nil {
204+ outcome = *s.outcome
205+ }
206+ // Pre and post stages of a step of the job have no context name, and
207+ // generated ids are hidden: only the main stage and embedded steps count.
208+ if s.n != nil && (s.parent != nil || s.stage == stageMain) && st != nil {
209+ j.state.SetStepResult(st.ID, string(outcome.conclusion()), string(res.conclusion()))
210+ }
211+ if s.parent == nil {
212+ j.recordOutcome(s.num, outcome.conclusion())
213+ }
214+ if s.parent != nil {
215+ if res == resFailed || res == resCanceled {
216+ m := merge(s.parent.result, res)
217+ s.parent.result = &m
218+ }
219+ return
220+ }
221+ if res == resFailed {
222+ m := merge(j.result, resFailed)
223+ j.result = &m
224+ j.state.SetStatus(statusName(m))
225+ }
226+}
227+
228+// runGuarded is the body of a step between its start and its end: the
229+// condition, the run with its cancellation watch, the commands' result,
230+// continue-on-error. ok is false when the env of the step did not evaluate (the
231+// error is reported by the caller).
232+func (j *job) runGuarded(s *stepRun, q qstep, ok bool) stepResult {
233+ num, st := s.num, s.st
234+ complete := func(r stepResult) stepResult { j.finishStep(s, r); return r }
235+ if !ok {
236+ return resFailed
237+ }
238+ // Cancellation seen before the step starts (StepsRunner.cs:188-197).
239+ cancelledAtStart := false
240+ select {
241+ case <-j.cancel.Done():
242+ cancelledAtStart = true
243+ j.mu.Lock()
244+ j.markCancelledLocked()
245+ j.mu.Unlock()
246+ default:
247+ }
248+ cond := func() (bool, error) {
249+ j.mu.Lock()
250+ defer j.mu.Unlock()
251+ switch {
252+ case q.setup != nil:
253+ return j.evalCondString("success()", s.env, j.statusFuncs())
254+ case q.cond != "":
255+ return j.evalCondString(q.cond, s.env, s.statusFuncs())
256+ }
257+ return st.EvaluateIf(context.Background(), j.evalEnv(s.env), s.statusFuncs())
258+ }
259+ // On runner shutdown conditions are not evaluated and the step is skipped.
260+ if cancelledAtStart && j.cancel.Reason() == CancelShutdown {
261+ return complete(resSkipped)
262+ }
263+ run, err := cond()
264+ switch {
265+ case err != nil:
266+ j.errorf(num, "%s", templateNotValid(err, false))
267+ return complete(resFailed)
268+ case !run:
269+ return complete(resSkipped)
270+ }
271+ // Cancellation while the step runs (StepsRunner.cs:149-186): the job becomes
272+ // cancelled and the step is stopped unless its condition still holds
273+ // (always()).
274+ stepCancel := make(chan struct{})
275+ var cancelOnce sync.Once
276+ closeCancel := func() { cancelOnce.Do(func() { close(stepCancel) }) }
277+ stopWatch := make(chan struct{})
278+ var watch sync.WaitGroup
279+ if !cancelledAtStart {
280+ watch.Add(1)
281+ go func() {
282+ defer watch.Done()
283+ select {
284+ case <-j.cancel.Done():
285+ case <-stopWatch:
286+ return
287+ }
288+ j.mu.Lock()
289+ j.markCancelledLocked()
290+ j.mu.Unlock()
291+ keep := false
292+ if j.cancel.Reason() != CancelShutdown {
293+ keep, _ = cond()
294+ }
295+ if !keep {
296+ closeCancel()
297+ }
298+ }()
299+ }
300+ // timeout-minutes (StepsRunner.RunStepAsync): the timer cancels the body like
301+ // a job cancellation does, and does not cancel the job. An embedded step
302+ // has its own timer, the step around it has its own.
303+ var r stepResult
304+ stopTimeout, minutes := func() (func(), int) { return func() {}, 0 }()
305+ if q.setup == nil {
306+ stopTimeout, minutes = j.armStepTimeout(s, closeCancel)
307+ if minutes > 0 {
308+ s.deadline = j.now().Add(time.Duration(minutes)*time.Minute + j.cfg.StepTimeoutSkew)
309+ }
310+ if p := s.parent; p != nil && !p.deadline.IsZero() {
311+ // The inner step has the time that is left to the step around it, and
312+ // no timeout of its own (the schema refuses one).
313+ s.deadline = p.deadline
314+ t := time.AfterFunc(time.Until(p.deadline), func() { s.timedOut.Store(true); closeCancel() })
315+ stopTimeout = func() { t.Stop() }
316+ }
317+ }
318+ if q.setup != nil {
319+ r = q.setup(s, stepCancel)
320+ } else {
321+ r = j.execute(s, stepCancel)
322+ }
323+ stopTimeout()
324+ close(stopWatch)
325+ watch.Wait()
326+ if minutes > 0 {
327+ r = j.stepTimedOut(s, r, minutes)
328+ }
329+ if s.parent != nil && r == resCanceled && s.timedOut.Load() && !j.cancelRequested() {
330+ // CompositeActionHandler.RunStepAsync (:497-506): an inner step stopped by
331+ // the timeout it inherited fails with a message that names no step.
332+ j.errorf(num, "The action has timed out.")
333+ r = resFailed
334+ }
335+
336+ // Merge the result of the commands (StepsRunner.cs:347-351).
337+ if s.cmdResult != nil {
338+ r = merge(&r, *s.cmdResult)
339+ }
340+ // continue-on-error (ExecutionContext.ApplyContinueOnError, :1422).
341+ if r == resFailed && st != nil && st.ContinueOnError != nil {
342+ coe, cerr := template.EvaluateBool(context.Background(), st.ContinueOnError, j.evalEnv(s.env))
343+ if cerr != nil {
344+ j.errorf(num, "The step failed and an error occurred when attempting to determine whether to continue on error.")
345+ j.errorf(num, "%s", cerr.Error())
346+ }
347+ if coe && cerr == nil {
348+ o := resFailed
349+ s.outcome = &o
350+ r = resSucceeded
351+ }
352+ }
353+ return complete(r)
354+}
M internal/guest/jobexec.go +7 -3
@@ -212,6 +212,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
212212 }
213213 canceller := executor.NewCanceller()
214214 rep := &frameReporter{conn: conn, failed: make(chan struct{})}
215+ res := newResolver(conn)
215216
216217 // Frames from the host while the job runs: a Cancel stops the job; a lost
217218 // host does too, like a runner shutdown. The reader ends with the stream: the
@@ -229,6 +230,9 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
229230 if c := f.GetCancel(); c != nil {
230231 canceller.Cancel(cancelKind(c.GetKind()))
231232 }
233+ if r := f.GetResolveResponse(); r != nil {
234+ res.deliver(r)
235+ }
232236 }
233237 }()
234238 // The executor's own context (SIGTERM forwarded by init: the sandbox is
@@ -245,18 +249,18 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
245249 }()
246250
247251 env := spec.GetRunnerEnvironment()
248- res := executor.Run(canceller, executor.Config{
252+ result := executor.Run(canceller, executor.Config{
249253 WorkflowPath: spec.GetWorkflowPath(), WorkflowYAML: []byte(spec.GetWorkflowYaml()), JobKey: spec.GetJobKey(),
250254 ContextsJSON: spec.GetContexts(), MatrixJSON: spec.GetMatrix(), Repo: spec.GetRepo(),
251255 TimeoutMinutes: int(spec.GetTimeoutMinutes()), Deadline: time.Duration(spec.GetDeadlineMs()) * time.Millisecond,
252256 Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},
253257 RunnerVersion: spec.GetRunnerVersion(),
254258 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
255- Debug: spec.GetDebug(), Reporter: rep, Log: zerowrap.FromCtx(ctx),
259+ Debug: spec.GetDebug(), Reporter: rep, Actions: executor.ActionsConfig{Resolver: res.resolve}, Log: zerowrap.FromCtx(ctx),
256260 SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(),
257261 TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay,
258262 })
259- if res.Conclusion == executor.ConclusionSuccess {
263+ if result.Conclusion == executor.ConclusionSuccess {
260264 return 0
261265 }
262266 return 1
A internal/guest/resolver.go +69 -0
@@ -0,0 +1,69 @@
1+// SPDX-License-Identifier: MIT
2+
3+package guest
4+
5+import (
6+ "context"
7+ "errors"
8+ "sync"
9+
10+ "bnema.dev/ermite-runner/internal/execstream"
11+ "bnema.dev/ermite-runner/internal/execstream/execpb"
12+ "bnema.dev/ermite-runner/internal/executor"
13+)
14+
15+// resolver sends ResolveRequest frames to the host and matches the
16+// ResolveResponse frames that come back (the job token stays in the host).
17+type resolver struct {
18+ conn *execstream.Conn
19+ mu sync.Mutex
20+ next int64
21+ wait map[int64]chan *execpb.ResolveResponse
22+}
23+
24+func newResolver(conn *execstream.Conn) *resolver {
25+ return &resolver{conn: conn, wait: map[int64]chan *execpb.ResolveResponse{}}
26+}
27+
28+func (r *resolver) deliver(resp *execpb.ResolveResponse) {
29+ r.mu.Lock()
30+ ch := r.wait[resp.Id]
31+ delete(r.wait, resp.Id)
32+ r.mu.Unlock()
33+ if ch != nil {
34+ ch <- resp
35+ }
36+}
37+
38+// resolve is executor.Config.Resolver.
39+func (r *resolver) resolve(ctx context.Context, uses []string) ([]executor.ResolvedAction, error) {
40+ ch := make(chan *execpb.ResolveResponse, 1)
41+ r.mu.Lock()
42+ r.next++
43+ id := r.next
44+ r.wait[id] = ch
45+ r.mu.Unlock()
46+ forget := func() { r.mu.Lock(); delete(r.wait, id); r.mu.Unlock() }
47+ req := &execpb.ResolveRequest{Id: id, Uses: uses}
48+ if err := r.conn.Send(&execpb.Frame{Body: &execpb.Frame_ResolveRequest{ResolveRequest: req}}); err != nil {
49+ forget()
50+ return nil, err
51+ }
52+ select {
53+ case resp := <-ch:
54+ if resp.Error != "" {
55+ return nil, errors.New(resp.Error)
56+ }
57+ out := make([]executor.ResolvedAction, len(resp.Actions))
58+ for i, a := range resp.Actions {
59+ out[i] = executor.ResolvedAction{
60+ Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSHA: a.ResolvedSha,
61+ TarballURL: a.TarballUrl, Token: a.Token, AuthOrigin: a.AuthOrigin, Error: a.Error,
62+ }
63+ }
64+ return out, nil
65+ case <-ctx.Done():
66+ forget()
67+ return nil, ctx.Err()
68+ }
69+}
A internal/guest/resolver_test.go +82 -0
@@ -0,0 +1,82 @@
1+// SPDX-License-Identifier: MIT
2+
3+package guest
4+
5+import (
6+ "context"
7+ "net"
8+ "testing"
9+
10+ "bnema.dev/ermite-runner/internal/execstream"
11+ "bnema.dev/ermite-runner/internal/execstream/execpb"
12+)
13+
14+// The resolver of the executor and the Resolve handler of the host meet on the
15+// stream: requests are answered by id, in any order, and an error of the host
16+// reaches the caller.
17+func TestResolverRoundTrip(t *testing.T) {
18+ ln, err := net.Listen("tcp", "127.0.0.1:0")
19+ if err != nil {
20+ t.Skip(err)
21+ }
22+ defer func() { _ = ln.Close() }()
23+ guestSide, err := net.Dial("tcp", ln.Addr().String())
24+ if err != nil {
25+ t.Fatal(err)
26+ }
27+ hostSide, err := ln.Accept()
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ defer func() { _ = hostSide.Close() }()
32+ defer func() { _ = guestSide.Close() }()
33+
34+ gconn := execstream.NewConn(guestSide)
35+ res := newResolver(gconn)
36+ go func() { // the guest's frame reader
37+ for {
38+ f, err := gconn.Recv()
39+ if err != nil {
40+ return
41+ }
42+ if r := f.GetResolveResponse(); r != nil {
43+ res.deliver(r)
44+ }
45+ }
46+ }()
47+ // The executor says Hello first, as it does at startup.
48+ if err := gconn.Send(&execpb.Frame{Body: &execpb.Frame_Hello{Hello: &execpb.Hello{}}}); err != nil {
49+ t.Fatal(err)
50+ }
51+ done := make(chan struct{})
52+ go func() {
53+ defer close(done)
54+ _, _, _ = execstream.DriveJob(hostSide, &execpb.Run{}, nil, execstream.JobHandler{
55+ Resolve: func(_ context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse {
56+ if len(r.Uses) == 1 && r.Uses[0] == "bad@v1" {
57+ return &execpb.ResolveResponse{Error: "no"}
58+ }
59+ out := &execpb.ResolveResponse{}
60+ for _, u := range r.Uses {
61+ out.Actions = append(out.Actions, &execpb.ResolvedAction{Ref: u, Token: "t-" + u, ResolvedSha: "sha"})
62+ }
63+ return out
64+ },
65+ })
66+ }()
67+ // DriveJob sends Run after Hello; the guest ignores it.
68+ got, err := res.resolve(context.Background(), []string{"a/b@v1", "c/d@v2"})
69+ if err != nil || len(got) != 2 || got[1].Ref != "c/d@v2" || got[1].Token != "t-c/d@v2" || got[0].ResolvedSHA != "sha" {
70+ t.Fatalf("%+v %v", got, err)
71+ }
72+ if _, err := res.resolve(context.Background(), []string{"bad@v1"}); err == nil || err.Error() != "no" {
73+ t.Fatalf("error not passed: %v", err)
74+ }
75+ ctx, cancel := context.WithCancel(context.Background())
76+ cancel()
77+ _ = guestSide.Close()
78+ if _, err := res.resolve(ctx, []string{"x/y@z"}); err == nil {
79+ t.Error("a closed stream must fail")
80+ }
81+ <-done
82+}
M internal/host/guestforge.go +6 -18
@@ -8,8 +8,9 @@ import (
88 "errors"
99 "fmt"
1010 "io"
11- "net/url"
1211 "strings"
12+
13+ "bnema.dev/ermite-runner/internal/urlorigin"
1314 )
1415
1516 // serverURLKeys are the keys of the github context that actions use to reach
@@ -110,23 +111,10 @@ func RewriteServerURLs(contexts, target string) (string, error) {
110111 }
111112
112113 // originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and
113-// without the default port, or "" for anything else (including URLs with
114-// credentials, which are never rewritten).
115-func originOf(raw string) string {
116- u, err := url.Parse(raw)
117- if err != nil || u.Host == "" || u.User != nil {
118- return ""
119- }
120- scheme := strings.ToLower(u.Scheme)
121- if scheme != "http" && scheme != "https" {
122- return ""
123- }
124- host, port := strings.ToLower(u.Hostname()), u.Port()
125- if port == "" || (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
126- return scheme + "://" + host
127- }
128- return scheme + "://" + host + ":" + port
129-}
114+// without the default port, an IPv6 host in brackets, or "" for anything else
115+// (including URLs with credentials, which are never rewritten). It is
116+// urlorigin.Prefix, the form the executor compares by urlorigin.Canonical.
117+func originOf(raw string) string { return urlorigin.Prefix(raw) }
130118
131119 func marshalNoEscape(v any) (json.RawMessage, error) {
132120 var b bytes.Buffer
M internal/host/guestforge_test.go +19 -0
@@ -112,6 +112,10 @@ func TestOriginOf(t *testing.T) {
112112 "https://Ermite.example/x": "https://ermite.example", "http://a.test:80": "http://a.test",
113113 "http://a.test:8088/p": "http://a.test:8088", "https://a.test:443": "https://a.test",
114114 "ftp://a.test": "", "a.test": "", "https://u@a.test": "", "": "",
115+ // GO-012: an IPv6 host stays in brackets, and the host "::1:8443" is not the
116+ // host "::1" on port 8443.
117+ "https://[::1]:8443/x": "https://[::1]:8443", "https://[::1:8443]/x": "https://[::1:8443]", "https://[::1]:443/": "https://[::1]",
118+ "http://[2001:DB8::1]:80": "http://[2001:db8::1]",
115119 } {
116120 if got := originOf(in); got != want {
117121 t.Errorf("originOf(%q) = %q, want %q", in, got, want)
@@ -146,3 +150,18 @@ func TestJobContextsAreRewrittenOnlyWithGuestForge(t *testing.T) {
146150 }
147151 }
148152 }
153+
154+// GO-012: guest forge access on an IPv6 address. The URLs of the contexts are
155+// rewritten to the runner's origin, the brackets and the port kept, and the
156+// origin of the other host with the look-alike spelling is not touched.
157+func TestRewriteServerURLsIPv6(t *testing.T) {
158+ in := `{"github":{"server_url":"https://[::1]:8443","api_url":"https://[::1]:8443/-/github/api","graphql_url":"https://[::1:8443]/x"}}`
159+ got, err := RewriteServerURLs(in, "http://[fd00::2]:8088")
160+ if err != nil {
161+ t.Fatal(err)
162+ }
163+ want := `{"github":{"server_url":"http://[fd00::2]:8088","api_url":"http://[fd00::2]:8088/-/github/api","graphql_url":"https://[::1:8443]/x"}}`
164+ if got != want {
165+ t.Errorf("got %s\nwant %s", got, want)
166+ }
167+}
M internal/host/job.go +12 -0
@@ -31,6 +31,7 @@ type jobClient interface {
3131 Heartbeat(context.Context, *connect.Request[ermite.HeartbeatRequest]) (*connect.Response[ermite.HeartbeatResponse], error)
3232 WatchJob(context.Context, *connect.Request[ermite.WatchJobRequest]) (*connect.ServerStreamForClient[ermite.WatchJobResponse], error)
3333 CompleteJob(context.Context, *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error)
34+ ResolveActions(context.Context, *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error)
3435 }
3536
3637 var _ jobClient = ermiteconnect.RunnerServiceClient(nil)
@@ -70,6 +71,11 @@ type jobRun struct {
7071 // contexts is Job.Contexts as handed to the executor: with the server URLs
7172 // pointed at the runner's URL when guest forge access is on.
7273 contexts string
74+ // Origins that are ermite for the archive tokens (see resolveResponse), and
75+ // the budget of resolution requests of the job (see resolveActions).
76+ publicOrigin, serverOrigin, guestForge string
77+ resolveRounds, resolveRefs int
78+ resolved map[string]*execpb.ResolvedAction // by "uses", for the job
7379 // features is what the server accepts; annLists are the annotations the
7480 // executor sent for steps that have not completed yet (see results.go).
7581 features jobFeatures
@@ -600,10 +606,15 @@ func failure() outcome { return outcome{conclusion: ermite.CiConclusion_CI_CONCL
600606 // execute starts the sandbox, runs the job in it and returns how it ended.
601607 func (j *jobRun) execute(ctx context.Context, rt *Runner, stopHard <-chan struct{}) outcome {
602608 var err error
609+ j.setOrigins(j.job.Contexts, rt.Cfg.URL, rt.Cfg.GuestForgeOrigin)
603610 if j.contexts, err = RewriteServerURLs(j.job.Contexts, rt.Cfg.GuestForgeOrigin); err != nil {
604611 j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err))
605612 return failure()
606613 }
614+ if j.contexts, err = SetGitHubToken(j.contexts, j.job.GithubToken); err != nil {
615+ j.failEarly(fmt.Sprintf("The job's contexts cannot be prepared: %v", err))
616+ return failure()
617+ }
607618 sb, err := rt.Driver.New()
608619 if err != nil {
609620 j.failEarly(fmt.Sprintf("The runner has no free sandbox: %v", err))
@@ -684,6 +695,7 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {
684695 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,
685696 Log: j.logLine,
686697 JobResult: func(r *execpb.JobResult) { j.mu.Lock(); j.result = r; j.mu.Unlock() },
698+ Resolve: j.resolveActions,
687699 StepAnnotations: j.stepAnnotations, StepSummary: j.stepSummary,
688700 })
689701 return exit, err
A internal/host/resolve.go +342 -0
@@ -0,0 +1,342 @@
1+// SPDX-License-Identifier: MIT
2+
3+package host
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "strings"
10+ "unicode/utf8"
11+
12+ "connectrpc.com/connect"
13+
14+ ermite "bnema.dev/ermite-api/gen/ermite"
15+
16+ "bnema.dev/ermite-runner/internal/execstream/execpb"
17+)
18+
19+// Limits of what the sandbox may ask the server through the host. The sandbox
20+// runs untrusted code, so the host does not trust its requests.
21+//
22+// Resolutions are cached for the job, by "uses" value: an action that was
23+// resolved once is never asked again (the same sha for the whole job is what the
24+// server promises anyway), so a local composite action used 500 times costs one
25+// resolution. The budget counts only what reaches the server:
26+//
27+// - a request from the sandbox has at most maxRequestRefs references (a
28+// workflow has at most 1,000 steps), split into calls of maxResolveRefs, the
29+// limit of ResolveActionsRequest;
30+// - a job makes at most maxResolveRounds calls (up to 10 levels of repository
31+// composites, then one call per local composite that brings new actions) and
32+// asks for at most maxResolveRefsTotal distinct references.
33+//
34+// These are explicit limits of ermite, not of GitHub: a job that needs more
35+// distinct actions than that fails "Set up job" with the message below.
36+const (
37+ maxRequestRefs = 1000
38+ maxResolveRefs = 100 // per call to the server: ResolveActionsRequest.refs
39+ maxResolveRounds = 128 // calls per job
40+ maxResolveRefsTotal = 2048 // distinct references per job
41+ maxRefLen = 1024 // the whole "uses" value
42+ maxOwnerRepoLen = 100 // owner and repository names
43+ maxPathElemLen = 255 // an element of the path of the action: a file name
44+ maxRefNameLen = 255
45+)
46+
47+// checkUses validates a "uses" value of a request: owner/repo[/path]@ref. The
48+// owner and the repository have the characters of repository names; the path of
49+// the action is a path in the repository (any characters but control ones, with
50+// no empty, "." or ".." element and no backslash, which the runner reads as a
51+// separator, action.ParseUses); the ref has the characters git allows. The
52+// server decides whether the action exists; this keeps garbage and oversized
53+// strings from reaching it.
54+func checkUses(u string) error {
55+ bad := func() error { return fmt.Errorf("%q is not a valid action reference", cut(u)) }
56+ if u == "" || len(u) > maxRefLen || !utf8.ValidString(u) {
57+ return bad()
58+ }
59+ name, ref, ok := strings.Cut(u, "@")
60+ if !ok || ref == "" || len(ref) > maxRefNameLen || strings.ContainsFunc(ref, badRefRune) {
61+ return bad()
62+ }
63+ segs := strings.Split(name, "/")
64+ if len(segs) < 2 {
65+ return bad()
66+ }
67+ for _, seg := range segs[:2] {
68+ if seg == "" || seg == "." || seg == ".." || len(seg) > maxOwnerRepoLen || strings.ContainsFunc(seg, badSegmentRune) {
69+ return bad()
70+ }
71+ }
72+ for _, seg := range segs[2:] {
73+ if seg == "" || seg == "." || seg == ".." || len(seg) > maxPathElemLen || strings.ContainsFunc(seg, badPathRune) {
74+ return bad()
75+ }
76+ }
77+ return nil
78+}
79+
80+// badPathRune: control characters (NUL included) and the backslash. Invalid
81+// UTF-8 is refused for the whole value by utf8.ValidString; a literal U+FFFD is a
82+// valid character.
83+func badPathRune(r rune) bool { return r < ' ' || r == 0x7f || r == '\\' }
84+
85+func cut(s string) string {
86+ if len(s) > 80 {
87+ return s[:80] + "..."
88+ }
89+ return s
90+}
91+
92+func badSegmentRune(r rune) bool {
93+ return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '-' && r != '_' && r != '.'
94+}
95+
96+// badRefRune: a ref is a branch, a tag or a sha; no control characters, spaces
97+// or characters git refuses.
98+func badRefRune(r rune) bool {
99+ return r <= ' ' || r == 0x7f || strings.ContainsRune("~^:?*[\\@", r) // "@": a "uses" has exactly one (action.ParseUses)
100+}
101+
102+// resolveActions answers a ResolveRequest of the executor with
103+// RunnerService.ResolveActions. The job token stays here: the executor only
104+// gets what the server returns (the per-archive tokens included, which it
105+// masks). Requests come one at a time (see execstream.JobHandler.Resolve). What
106+// was resolved before in the job comes from the cache; the rest goes to the
107+// server in calls of at most maxResolveRefs, under the budget above. A call that
108+// fails without a verdict is retried like the other job calls; a failure of the
109+// call itself is reported to the executor, which fails "Set up job"
110+// (ActionManager: FailedToResolveActionDownloadInfoException). ctx ends with the
111+// session.
112+func (j *jobRun) resolveActions(ctx context.Context, req *execpb.ResolveRequest) *execpb.ResolveResponse {
113+ fail := func(format string, a ...any) *execpb.ResolveResponse {
114+ return &execpb.ResolveResponse{Error: fmt.Sprintf(format, a...)}
115+ }
116+ if len(req.Uses) == 0 {
117+ return fail("an empty request for action resolution")
118+ }
119+ if len(req.Uses) > maxRequestRefs {
120+ return fail("cannot resolve %d actions at once (the limit is %d)", len(req.Uses), maxRequestRefs)
121+ }
122+ for _, u := range req.Uses {
123+ if err := checkUses(u); err != nil {
124+ return fail("%v", err)
125+ }
126+ }
127+ // The job ending, or the session, stops the calls.
128+ ctx, cancel := context.WithCancel(ctx)
129+ defer cancel()
130+ stop := context.AfterFunc(j.ctx, cancel)
131+ defer stop()
132+
133+ got := map[string]*execpb.ResolvedAction{}
134+ var pending []string
135+ j.mu.Lock()
136+ for _, u := range req.Uses {
137+ if _, dup := got[u]; dup {
138+ continue
139+ }
140+ if c, ok := j.resolved[u]; ok {
141+ got[u] = c
142+ continue
143+ }
144+ got[u] = nil
145+ pending = append(pending, u)
146+ }
147+ j.mu.Unlock()
148+ for len(pending) > 0 {
149+ n := min(len(pending), maxResolveRefs)
150+ chunk := pending[:n]
151+ pending = pending[n:]
152+ j.mu.Lock()
153+ j.resolveRounds++
154+ j.resolveRefs += len(chunk)
155+ over := j.resolveRounds > maxResolveRounds || j.resolveRefs > maxResolveRefsTotal
156+ j.mu.Unlock()
157+ if over {
158+ return fail("this job asked to resolve too many actions (the limits are %d requests and %d distinct references)", maxResolveRounds, maxResolveRefsTotal)
159+ }
160+ res, errResp := j.callResolve(ctx, chunk)
161+ if errResp != nil {
162+ return errResp
163+ }
164+ j.mu.Lock()
165+ for i, u := range chunk {
166+ got[u] = res[i]
167+ if res[i].Error == "" { // an error is not cached: it may be a passing one
168+ if j.resolved == nil {
169+ j.resolved = map[string]*execpb.ResolvedAction{}
170+ }
171+ j.resolved[u] = res[i]
172+ }
173+ }
174+ j.mu.Unlock()
175+ }
176+ out := &execpb.ResolveResponse{}
177+ for _, u := range req.Uses {
178+ out.Actions = append(out.Actions, got[u])
179+ }
180+ return out
181+}
182+
183+// callResolve makes one call to the server, with retries, and converts the
184+// answer; the second result is the error to send to the executor.
185+func (j *jobRun) callResolve(ctx context.Context, uses []string) ([]*execpb.ResolvedAction, *execpb.ResolveResponse) {
186+ fail := func(format string, a ...any) ([]*execpb.ResolvedAction, *execpb.ResolveResponse) {
187+ return nil, &execpb.ResolveResponse{Error: fmt.Sprintf(format, a...)}
188+ }
189+ refs := make([]*ermite.ActionRef, len(uses))
190+ for i, u := range uses {
191+ refs[i] = &ermite.ActionRef{Uses: u}
192+ }
193+ b := backoff{min: j.t.BackoffMin, max: j.t.BackoffMax}
194+ for attempt := 1; ; attempt++ {
195+ cctx, ccancel := context.WithTimeout(ctx, j.t.CallTimeout)
196+ resp, err := j.client.ResolveActions(cctx, connect.NewRequest(&ermite.ResolveActionsRequest{JobId: j.job.JobId, Refs: refs}))
197+ ccancel()
198+ if err == nil {
199+ conv := j.resolveResponse(resp.Msg, len(uses))
200+ if conv.Error != "" {
201+ return fail("%s", conv.Error)
202+ }
203+ return conv.Actions, nil
204+ }
205+ if ctx.Err() != nil {
206+ return fail("the job was cancelled while its actions were resolved")
207+ }
208+ switch classify(err) {
209+ case kindEnded, kindAbandoned:
210+ j.abandon(err)
211+ return fail("the server ended the job")
212+ case kindPermanent:
213+ return fail("resolving actions failed: %s", connect.CodeOf(err))
214+ }
215+ j.log.Warn().Err(err).Int("attempt", attempt).Msg("ResolveActions failed")
216+ if attempt >= 3 || !sleep(ctx, b.next()) {
217+ return fail("resolving actions failed: %s", connect.CodeOf(err))
218+ }
219+ }
220+}
221+
222+// resolveResponse converts the answer of the server. The token of an archive is
223+// a secret that the sandbox sends in an Authorization header, so it only goes
224+// with an archive of the ermite source whose tarball URL is on an origin this
225+// host knows to be ermite: the public origin of the job (github.server_url) or
226+// the origin of the runner's own URL. The origin the sandbox must send it to is
227+// AuthOrigin; with guest forge access the tarball URL on the public origin is
228+// rewritten to the runner's, like the URLs of the contexts. A token that does
229+// not satisfy this is dropped (the download then fails like an unauthorised
230+// one).
231+func (j *jobRun) resolveResponse(m *ermite.ResolveActionsResponse, want int) *execpb.ResolveResponse {
232+ if len(m.Actions) != want {
233+ return &execpb.ResolveResponse{Error: fmt.Sprintf("the server resolved %d actions out of %d", len(m.Actions), want)}
234+ }
235+ out := &execpb.ResolveResponse{}
236+ for _, a := range m.Actions {
237+ ra := &execpb.ResolvedAction{
238+ Ref: a.Ref, NameWithOwner: a.NameWithOwner, Path: a.Path, ResolvedSha: a.ResolvedSha,
239+ TarballUrl: a.TarballUrl, Source: a.Source.String(), Error: a.Error,
240+ }
241+ if a.Source == ermite.ActionSource_ACTION_SOURCE_ERMITE {
242+ if origin := originOf(a.TarballUrl); origin != "" {
243+ if j.guestForge != "" && origin == j.publicOrigin {
244+ ra.TarballUrl = j.guestForge + strings.TrimPrefix(a.TarballUrl, a.TarballUrl[:strings.Index(a.TarballUrl, "://")+3+hostLen(a.TarballUrl)])
245+ origin = j.guestForge
246+ }
247+ if a.Token != "" && (origin == j.guestForge || origin == j.publicOrigin || origin == j.serverOrigin) {
248+ ra.Token, ra.AuthOrigin = a.Token, origin
249+ }
250+ }
251+ }
252+ out.Actions = append(out.Actions, ra)
253+ }
254+ return out
255+}
256+
257+// setOrigins records the origins that are ermite for this job: the public one
258+// (the origin of github.server_url as the server sent it), the runner's own
259+// (ERMITE_RUNNER_URL) and the guest forge origin, "" when it is off.
260+func (j *jobRun) setOrigins(contexts, serverURL, guestForge string) {
261+ j.serverOrigin = originOf(serverURL)
262+ j.guestForge = guestForge
263+ if guestForge != "" {
264+ j.guestForge = originOf(guestForge)
265+ }
266+ if top, err := parseObject([]byte(contexts)); err == nil {
267+ for _, m := range top {
268+ if m.key != "github" {
269+ continue
270+ }
271+ gh, err := parseObject(m.val)
272+ if err != nil {
273+ break
274+ }
275+ for _, g := range gh {
276+ var s string
277+ if g.key == "server_url" && json.Unmarshal(g.val, &s) == nil {
278+ j.publicOrigin = originOf(s)
279+ }
280+ }
281+ }
282+ }
283+}
284+
285+// SetGitHubToken returns the contexts JSON of a job with github.token set to
286+// token (the job repository token, Job.github_token). The key is added first,
287+// like the runner does (ExecutionContext.cs:1023: token, then what the server
288+// gave); an empty token leaves the contexts as they are.
289+func SetGitHubToken(contexts, token string) (string, error) {
290+ if token == "" {
291+ return contexts, nil
292+ }
293+ top, err := parseObject([]byte(contexts))
294+ if err != nil {
295+ return "", fmt.Errorf("job contexts: %w", err)
296+ }
297+ gi := -1
298+ for i, m := range top {
299+ if m.key == "github" {
300+ gi = i
301+ }
302+ }
303+ var gh []jsonMember
304+ if gi >= 0 {
305+ if gh, err = parseObject(top[gi].val); err != nil {
306+ return "", fmt.Errorf("job contexts: github: %w", err)
307+ }
308+ }
309+ tv, err := json.Marshal(token)
310+ if err != nil {
311+ return "", err
312+ }
313+ set := false
314+ for i, m := range gh {
315+ if m.key == "token" {
316+ gh[i].val, set = tv, true
317+ }
318+ }
319+ if !set {
320+ gh = append([]jsonMember{{"token", tv}}, gh...)
321+ }
322+ ghv, err := encodeObject(gh)
323+ if err != nil {
324+ return "", err
325+ }
326+ if gi >= 0 {
327+ top[gi].val = ghv
328+ } else {
329+ top = append(top, jsonMember{"github", ghv})
330+ }
331+ out, err := encodeObject(top)
332+ return string(out), err
333+}
334+
335+// hostLen is the length of the authority of an absolute URL.
336+func hostLen(u string) int {
337+ rest := u[strings.Index(u, "://")+3:]
338+ if i := strings.IndexAny(rest, "/?#"); i >= 0 {
339+ return i
340+ }
341+ return len(rest)
342+}
A internal/host/resolve_test.go +305 -0
@@ -0,0 +1,305 @@
1+// SPDX-License-Identifier: MIT
2+
3+package host
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "connectrpc.com/connect"
14+ "github.com/bnema/zerowrap"
15+
16+ ermite "bnema.dev/ermite-api/gen/ermite"
17+
18+ "bnema.dev/ermite-runner/internal/execstream/execpb"
19+)
20+
21+type resolveStub struct {
22+ stubClient
23+ got *ermite.ResolveActionsRequest
24+ resp *ermite.ResolveActionsResponse
25+ err error
26+ fn func(*ermite.ResolveActionsRequest) *ermite.ResolveActionsResponse
27+ onCall func(*ermite.ResolveActionsRequest)
28+ calls int
29+}
30+
31+func (c *resolveStub) ResolveActions(_ context.Context, r *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) {
32+ c.got = r.Msg
33+ c.calls++
34+ if c.onCall != nil {
35+ c.onCall(r.Msg)
36+ }
37+ if c.err != nil {
38+ return nil, c.err
39+ }
40+ if c.fn != nil {
41+ return connect.NewResponse(c.fn(r.Msg)), nil
42+ }
43+ return connect.NewResponse(c.resp), nil
44+}
45+
46+func resolveJob(c jobClient) *jobRun {
47+ j := &jobRun{client: c, ctx: context.Background(), job: &ermite.Job{JobId: "j1"}, log: zerowrap.Logger{},
48+ t: Timing{CallTimeout: time.Second, BackoffMin: time.Millisecond, BackoffMax: time.Millisecond}}
49+ j.setOrigins(`{"github":{"server_url":"https://ermite.example"}}`, "https://ermite.example", "")
50+ return j
51+}
52+
53+func TestResolveActions(t *testing.T) {
54+ c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{
55+ {Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSha: strings.Repeat("a", 40), TarballUrl: "https://ermite.example/t", Token: "tok", Source: ermite.ActionSource_ACTION_SOURCE_ERMITE},
56+ {Ref: "a/c@v1", Error: "boom"},
57+ }}}
58+ resp := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Id: 7, Uses: []string{"a/b@v1", "a/c@v1"}})
59+ if resp.Error != "" || len(resp.Actions) != 2 {
60+ t.Fatalf("%+v", resp)
61+ }
62+ if c.got.JobId != "j1" || len(c.got.Refs) != 2 || c.got.Refs[1].Uses != "a/c@v1" {
63+ t.Errorf("request %+v", c.got)
64+ }
65+ if a := resp.Actions[0]; a.Token != "tok" || a.AuthOrigin != "https://ermite.example" || a.TarballUrl != "https://ermite.example/t" || a.NameWithOwner != "a/b" {
66+ t.Errorf("%+v", a)
67+ }
68+ if resp.Actions[1].Error != "boom" {
69+ t.Errorf("%+v", resp.Actions[1])
70+ }
71+}
72+
73+func TestResolveActionsFailures(t *testing.T) {
74+ c := &resolveStub{err: connect.NewError(connect.CodeInvalidArgument, nil)}
75+ if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); !strings.Contains(r.Error, "invalid_argument") {
76+ t.Errorf("%+v", r)
77+ }
78+ c = &resolveStub{resp: &ermite.ResolveActionsResponse{}}
79+ if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); r.Error == "" {
80+ t.Errorf("a short answer must fail: %+v", r)
81+ }
82+ many := make([]string, maxResolveRefs+1)
83+ if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: many}); r.Error == "" {
84+ t.Error("more than 100 refs must fail")
85+ }
86+}
87+
88+func TestSetGitHubToken(t *testing.T) {
89+ cases := map[string]string{
90+ `{"github":{"job":"j","token":""},"needs":{}}`: `{"github":{"job":"j","token":"T"},"needs":{}}`,
91+ `{"github":{"job":"j"}}`: `{"github":{"token":"T","job":"j"}}`,
92+ `{"needs":{}}`: `{"needs":{},"github":{"token":"T"}}`,
93+ }
94+ for in, want := range cases {
95+ got, err := SetGitHubToken(in, "T")
96+ if err != nil || got != want {
97+ t.Errorf("%s: %s %v, want %s", in, got, err, want)
98+ }
99+ }
100+ if got, _ := SetGitHubToken(`{"a":1}`, ""); got != `{"a":1}` {
101+ t.Errorf("empty token changed the contexts: %s", got)
102+ }
103+ if _, err := SetGitHubToken(`[]`, "T"); err == nil {
104+ t.Error("not an object")
105+ }
106+ var m map[string]any
107+ if got, _ := SetGitHubToken(`{}`, `a"b\`); json.Unmarshal([]byte(got), &m) != nil {
108+ t.Errorf("invalid JSON: %s", got)
109+ }
110+}
111+
112+func TestCheckUses(t *testing.T) {
113+ long := func(n int) string { return strings.Repeat("a", n) }
114+ for u, ok := range map[string]bool{
115+ "actions/checkout@v4": true, "o/r/sub/dir@refs/heads/feat/x": true, "o/r@" + strings.Repeat("a", 40): true, "o/r.go_x-y@v1.2.3": true,
116+ // GO-011: the path of the action is a path, not a repository name.
117+ "o/r/action+tools@v1": true, "o/r/déploiement/日本語@v1": true, "o/r/with space/x@v1": true, "o/r/a(b)[c]{d}~e#f%g@v1": true,
118+ "o/r/a\ufffdb@v1": true, "o/r/\ufffd@v1": true, // a literal U+FFFD is a valid character
119+ "o/r/" + long(255) + "@v1": true, "o/r/.github/actions/x@v1": true, "o/r/.hidden@v1": true, "o/r/a..b@v1": true,
120+ "": false, "o/r": false, "o@v1": false, "o/r@": false, "/r@v1": false, "o//r@v1": false, "o/../r@v1": false, "o/r/..@v1": false,
121+ "o/r/a/../b@v1": false, "o/r/./a@v1": false, "o/r/a//b@v1": false, "o/r/a/@v1": false,
122+ "o/r/a\\b@v1": false, "o/r/a\x00b@v1": false, "o/r/a\nb@v1": false, "o/r/a\x7fb@v1": false, "o/r/\xff@v1": false, "o/r/a\xc3(b@v1": false, "o/r/\xed\xa0\x80@v1": false,
123+ "o/r/" + long(256) + "@v1": false, long(101) + "/r@v1": false, "o/" + long(101) + "@v1": false,
124+ "o/r@" + long(256): false, "o/r/" + strings.Repeat("a/", 600) + "x@v1": false,
125+ "o/r@v1 x": false, "o/r@v1\n": false, "o/r@v~1": false, "o/r@v:1": false, "o /r@v1": false, "o/r@v1@v2": false,
126+ "o/r\x00@v1": false, "ö/r@v1": false, "o/r+x@v1": false, "o/r:x@v1": false,
127+ } {
128+ if err := checkUses(u); (err == nil) != ok {
129+ t.Errorf("%q: %v, want ok=%v", cut(u), err, ok)
130+ }
131+ }
132+}
133+
134+// GO-003: whatever the sandbox sends, the server is called with valid
135+// references only, and with a bounded number of calls and references.
136+func TestResolveActionsRejectsAndBudgets(t *testing.T) {
137+ c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Ref: "a/b@v1"}}}}
138+ j := resolveJob(c)
139+ for _, uses := range [][]string{nil, {}, {""}, {"nonsense"}, {"a/b@v1", "bad ref"}} {
140+ c.got = nil
141+ if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: uses}); r.Error == "" || c.got != nil {
142+ t.Errorf("%q: error %q, called=%v", uses, r.Error, c.got != nil)
143+ }
144+ }
145+ // Distinct references, one per request: the rounds are the limit.
146+ c.resp = nil
147+ j = resolveJob(c)
148+ c.fn = func(r *ermite.ResolveActionsRequest) *ermite.ResolveActionsResponse { return echo(r) }
149+ var calls int
150+ for i := 0; i < maxResolveRounds+20; i++ {
151+ c.got = nil
152+ r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{fmt.Sprintf("a/b%d@v1", i)}})
153+ if c.got != nil {
154+ calls++
155+ }
156+ if i >= maxResolveRounds && r.Error == "" {
157+ t.Fatalf("round %d is over the budget and was served", i)
158+ }
159+ }
160+ if calls != maxResolveRounds {
161+ t.Errorf("%d calls, the budget is %d", calls, maxResolveRounds)
162+ }
163+ // Distinct references in big requests: the references are the limit.
164+ j = resolveJob(c)
165+ served := 0
166+ for i := 0; i < 40; i++ {
167+ big := make([]string, maxResolveRefs)
168+ for k := range big {
169+ big[k] = fmt.Sprintf("a/b%d_%d@v1", i, k)
170+ }
171+ if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: big}); r.Error == "" {
172+ served++
173+ }
174+ }
175+ if served != maxResolveRefsTotal/maxResolveRefs {
176+ t.Errorf("served %d requests of %d refs, the budget is %d refs", served, maxResolveRefs, maxResolveRefsTotal)
177+ }
178+}
179+
180+func echo(r *ermite.ResolveActionsRequest) *ermite.ResolveActionsResponse {
181+ out := &ermite.ResolveActionsResponse{}
182+ for _, ref := range r.Refs {
183+ out.Actions = append(out.Actions, &ermite.ResolvedAction{Ref: ref.Uses, NameWithOwner: "a/b"})
184+ }
185+ return out
186+}
187+
188+// GO-010: a resolution is cached for the job, so repeated invocations of a local
189+// composite action cost nothing, and a big request is split into calls.
190+func TestResolveActionsCacheAndBatches(t *testing.T) {
191+ c := &resolveStub{}
192+ c.fn = echo
193+ j := resolveJob(c)
194+ for i := 0; i < 500; i++ {
195+ r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1", "a/c/sub@v2"}})
196+ if r.Error != "" || len(r.Actions) != 2 || r.Actions[1].Ref != "a/c/sub@v2" {
197+ t.Fatalf("invocation %d: %+v", i, r)
198+ }
199+ }
200+ if c.calls != 1 {
201+ t.Errorf("%d calls for 500 invocations of the same actions", c.calls)
202+ }
203+ // A new action goes through; only it is asked.
204+ c.got = nil
205+ if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1", "a/new@v1"}}); r.Error != "" || len(c.got.Refs) != 1 || c.got.Refs[0].Uses != "a/new@v1" {
206+ t.Errorf("%+v %v", r, c.got)
207+ }
208+ // Duplicates in one request are asked once, answered in order.
209+ j = resolveJob(c)
210+ c.calls = 0
211+ r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/x@v1", "a/y@v1", "a/x@v1"}})
212+ if r.Error != "" || len(r.Actions) != 3 || r.Actions[2].Ref != "a/x@v1" || r.Actions[1].Ref != "a/y@v1" || c.calls != 1 || len(c.got.Refs) != 2 {
213+ t.Errorf("%+v calls %d", r, c.calls)
214+ }
215+ // 250 distinct references: three calls of at most 100, in order.
216+ j = resolveJob(c)
217+ c.calls = 0
218+ var sizes []int
219+ c.onCall = func(r *ermite.ResolveActionsRequest) { sizes = append(sizes, len(r.Refs)) }
220+ many := make([]string, 250)
221+ for i := range many {
222+ many[i] = fmt.Sprintf("a/b%d@v1", i)
223+ }
224+ r = j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: many})
225+ if r.Error != "" || len(r.Actions) != 250 || r.Actions[249].Ref != many[249] || fmt.Sprint(sizes) != "[100 100 50]" {
226+ t.Errorf("%q calls %v", r.Error, sizes)
227+ }
228+ // An error of one action is not cached.
229+ c.fn = func(r *ermite.ResolveActionsRequest) *ermite.ResolveActionsResponse {
230+ return &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Ref: r.Refs[0].Uses, Error: "not found"}}}
231+ }
232+ j = resolveJob(c)
233+ c.calls = 0
234+ _ = j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/gone@v1"}})
235+ _ = j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/gone@v1"}})
236+ if c.calls != 2 {
237+ t.Errorf("an error was cached: %d calls", c.calls)
238+ }
239+ // More than the request limit.
240+ if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: make([]string, maxRequestRefs+1)}); r.Error == "" {
241+ t.Error("a request over the limit was served")
242+ }
243+}
244+
245+// GO-002 on the host side: the token only goes with an ermite archive whose URL
246+// is on an origin of ermite; with guest forge access the URL is rewritten.
247+func TestResolveResponseTokenOrigin(t *testing.T) {
248+ j := resolveJob(&resolveStub{})
249+ ermiteSrc, ghSrc := ermite.ActionSource_ACTION_SOURCE_ERMITE, ermite.ActionSource_ACTION_SOURCE_GITHUB
250+ conv := func(j *jobRun, src ermite.ActionSource, url, tok string) *execpb.ResolvedAction {
251+ r := j.resolveResponse(&ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Source: src, TarballUrl: url, Token: tok}}}, 1)
252+ return r.Actions[0]
253+ }
254+ if a := conv(j, ermiteSrc, "https://ermite.example/-/github/api/repos/o/r/tarball/abc", "t"); a.Token != "t" || a.AuthOrigin != "https://ermite.example" {
255+ t.Errorf("%+v", a)
256+ }
257+ for name, a := range map[string]*execpb.ResolvedAction{
258+ "github source": conv(j, ghSrc, "https://ermite.example/x", "t"),
259+ "foreign origin": conv(j, ermiteSrc, "https://evil.example/x", "t"),
260+ "subdomain": conv(j, ermiteSrc, "https://x.ermite.example/x", "t"),
261+ "other port": conv(j, ermiteSrc, "https://ermite.example:8443/x", "t"),
262+ "other scheme": conv(j, ermiteSrc, "http://ermite.example/x", "t"),
263+ "not a url": conv(j, ermiteSrc, "ermite.example/x", "t"),
264+ "credentials in it": conv(j, ermiteSrc, "https://u:p@ermite.example/x", "t"),
265+ } {
266+ if a.Token != "" || a.AuthOrigin != "" {
267+ t.Errorf("%s: token kept: %+v", name, a)
268+ }
269+ }
270+ // No token, no origin: github.com archives are anonymous.
271+ if a := conv(j, ghSrc, "https://codeload.github.com/o/r/tar.gz/abc", ""); a.Token != "" || a.AuthOrigin != "" {
272+ t.Errorf("%+v", a)
273+ }
274+ // Guest forge access: the sandbox reaches ermite at the runner's origin.
275+ j.setOrigins(`{"github":{"server_url":"https://ermite.example"}}`, "https://runner.internal:8088", "http://10.0.2.2:8088")
276+ a := conv(j, ermiteSrc, "https://ermite.example/-/github/api/repos/o/r/tarball/abc", "t")
277+ if a.TarballUrl != "http://10.0.2.2:8088/-/github/api/repos/o/r/tarball/abc" || a.AuthOrigin != "http://10.0.2.2:8088" || a.Token != "t" {
278+ t.Errorf("%+v", a)
279+ }
280+}
281+
282+// GO-012 on the archive tokens: with IPv6 hosts the origin named for the token is
283+// exact, and a look-alike is not accepted.
284+func TestResolveResponseTokenOriginIPv6(t *testing.T) {
285+ j := resolveJob(&resolveStub{})
286+ j.setOrigins(`{"github":{"server_url":"https://[::1]:8443"}}`, "https://[fd00::9]", "http://[fd00::2]:8088")
287+ ermiteSrc := ermite.ActionSource_ACTION_SOURCE_ERMITE
288+ conv := func(url string) *execpb.ResolvedAction {
289+ return j.resolveResponse(&ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Source: ermiteSrc, TarballUrl: url, Token: "t"}}}, 1).Actions[0]
290+ }
291+ // The public origin is rewritten to the guest one.
292+ if a := conv("https://[::1]:8443/-/github/api/repos/o/r/tarball/abc"); a.Token != "t" || a.AuthOrigin != "http://[fd00::2]:8088" || a.TarballUrl != "http://[fd00::2]:8088/-/github/api/repos/o/r/tarball/abc" {
293+ t.Errorf("%+v", a)
294+ }
295+ // The runner's own origin, default port.
296+ if a := conv("https://[fd00::9]:443/x"); a.Token != "t" || a.AuthOrigin != "https://[fd00::9]" {
297+ t.Errorf("%+v", a)
298+ }
299+ // The look-alike host "::1:8443" and other ports are not ermite.
300+ for _, u := range []string{"https://[::1:8443]/x", "https://[::1]:8444/x", "http://[::1]:8443/x", "https://[::1]/x"} {
301+ if a := conv(u); a.Token != "" || a.AuthOrigin != "" {
302+ t.Errorf("%s: token kept: %+v", u, a)
303+ }
304+ }
305+}
M internal/host/shutdown_test.go +4 -0
@@ -50,6 +50,10 @@ func (c *stubClient) WatchJob(context.Context, *connect.Request[ermite.WatchJobR
5050 return nil, connect.NewError(connect.CodeUnimplemented, nil)
5151 }
5252
53+func (c *stubClient) ResolveActions(context.Context, *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) {
54+ return nil, connect.NewError(connect.CodeUnimplemented, nil)
55+}
56+
5357 func (c *stubClient) CompleteJob(ctx context.Context, _ *connect.Request[ermite.CompleteJobRequest]) (*connect.Response[ermite.CompleteJobResponse], error) {
5458 c.mu.Lock()
5559 c.deadline, _ = ctx.Deadline()
A internal/urlorigin/urlorigin.go +60 -0
@@ -0,0 +1,60 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package urlorigin gives the one canonical form of the origin of an http(s)
4+// URL that the host and the executor both compare: the archive token of an
5+// action goes only to the origin the host named, so the two sides must agree on
6+// what "the same origin" means, whatever the spelling of the URL.
7+package urlorigin
8+
9+import (
10+ "net"
11+ "net/url"
12+ "strings"
13+)
14+
15+// Canonical returns "scheme://host:port" for an absolute http or https URL: the
16+// scheme and the host lower-cased, the default port (80, 443) filled in, an IPv6
17+// host in brackets (net.JoinHostPort), so that "https://[::1]:8443/x" and
18+// "https://[::1:8443]/x" (a different host, without a port) differ, and
19+// "https://a.test" and "https://a.test:443" are the same. It returns "" for
20+// anything else, including a URL with credentials, which never carries a token.
21+func Canonical(raw string) string {
22+ u, err := url.Parse(raw)
23+ if err != nil || u.Host == "" || u.User != nil {
24+ return ""
25+ }
26+ scheme := strings.ToLower(u.Scheme)
27+ port, ok := effectivePort(scheme, u.Port())
28+ if !ok || u.Hostname() == "" {
29+ return ""
30+ }
31+ return scheme + "://" + net.JoinHostPort(strings.ToLower(u.Hostname()), port)
32+}
33+
34+func effectivePort(scheme, port string) (string, bool) {
35+ switch {
36+ case port != "":
37+ if scheme != "http" && scheme != "https" {
38+ return "", false
39+ }
40+ return port, true
41+ case scheme == "http":
42+ return "80", true
43+ case scheme == "https":
44+ return "443", true
45+ }
46+ return "", false
47+}
48+
49+// Prefix returns the origin as it is written at the start of a URL: the same
50+// as Canonical without the default port, which is how a URL is built from it.
51+func Prefix(raw string) string {
52+ c := Canonical(raw)
53+ switch {
54+ case strings.HasPrefix(c, "http://") && strings.HasSuffix(c, ":80"):
55+ return strings.TrimSuffix(c, ":80")
56+ case strings.HasPrefix(c, "https://") && strings.HasSuffix(c, ":443"):
57+ return strings.TrimSuffix(c, ":443")
58+ }
59+ return c
60+}
A internal/urlorigin/urlorigin_test.go +60 -0
@@ -0,0 +1,60 @@
1+// SPDX-License-Identifier: MIT
2+
3+package urlorigin
4+
5+import "testing"
6+
7+func TestCanonical(t *testing.T) {
8+ for in, want := range map[string]string{
9+ "https://Ermite.example/x": "https://ermite.example:443",
10+ "HTTPS://ermite.example": "https://ermite.example:443",
11+ "https://ermite.example:443/x": "https://ermite.example:443",
12+ "http://a.test:80": "http://a.test:80",
13+ "http://a.test": "http://a.test:80",
14+ "http://a.test:8088/p?q#f": "http://a.test:8088",
15+ "https://[::1]:8443/x": "https://[::1]:8443",
16+ "https://[::1]/x": "https://[::1]:443",
17+ "https://[::1]:443": "https://[::1]:443",
18+ "https://[2001:DB8::1]:8443": "https://[2001:db8::1]:8443",
19+ "http://[::1]:80": "http://[::1]:80",
20+ "http://10.0.2.2:8088": "http://10.0.2.2:8088",
21+ "ftp://a.test": "", "a.test": "", "https://u@a.test": "", "https://u:p@a.test": "", "": "", "https://": "", "//a.test": "",
22+ "https://[::1:8443]/x": "https://[::1:8443]:443", // an IPv6 host with no port at all
23+ } {
24+ if got := Canonical(in); got != want {
25+ t.Errorf("Canonical(%q) = %q, want %q", in, got, want)
26+ }
27+ }
28+ // GO-012: the host ::1 on port 8443 is not the host ::1:8443.
29+ if Canonical("https://[::1]:8443/x") == Canonical("https://[::1:8443]/x") {
30+ t.Error("two different origins collide")
31+ }
32+ // Spellings of one origin agree.
33+ for _, pair := range [][2]string{
34+ {"https://a.test", "https://A.TEST:443/path"}, {"http://a.test:80/", "http://a.test"}, {"https://[::1]", "https://[::1]:443"},
35+ } {
36+ if Canonical(pair[0]) != Canonical(pair[1]) {
37+ t.Errorf("%q and %q differ: %q %q", pair[0], pair[1], Canonical(pair[0]), Canonical(pair[1]))
38+ }
39+ }
40+ // Other ports, schemes and hosts differ.
41+ for _, pair := range [][2]string{
42+ {"https://a.test", "http://a.test"}, {"https://a.test", "https://a.test:8443"}, {"https://a.test", "https://b.a.test"}, {"http://a.test", "http://a.test:443"},
43+ } {
44+ if Canonical(pair[0]) == Canonical(pair[1]) {
45+ t.Errorf("%q and %q collide", pair[0], pair[1])
46+ }
47+ }
48+}
49+
50+func TestPrefix(t *testing.T) {
51+ for in, want := range map[string]string{
52+ "https://Ermite.example:443/x": "https://ermite.example", "http://a.test:80": "http://a.test", "http://a.test:8088": "http://a.test:8088",
53+ "https://[::1]:443": "https://[::1]", "http://[::1]:80/": "http://[::1]", "https://[::1]:8443": "https://[::1]:8443",
54+ "http://10.0.2.2:8088": "http://10.0.2.2:8088", "https://a.test:80": "https://a.test:80", "ftp://x": "",
55+ } {
56+ if got := Prefix(in); got != want {
57+ t.Errorf("Prefix(%q) = %q, want %q", in, got, want)
58+ }
59+ }
60+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close