detached 0039612

feat(ci): disable, enable and list workflows per repository

commit
00396122d39c11f2d7ecd211a94f823937ff76c0
author
brice · · 2026-10-18 19:44 UTC
parent
dfd6f0b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
refs
tag: v0.27.0
40 files · +2840 -966
M docs/ci-events.md +1 -1
@@ -39,7 +39,7 @@ Its cache is read-only and restores only default-branch entries.
3939 ermite workflow run <file> --repo <repo> [--ref <branch|tag>] [-f name=value …]
4040 ```
4141
42-- Needs `ci:write`. `<file>` is a path or a name under `.github/workflows`. `--ref` defaults to the default branch.
42+- Needs `ci:write`. `<file>` is a path or a name under `.github/workflows`. `--ref` defaults to the default branch. A disabled workflow (`ermite workflow disable`) is refused with `failed_precondition`.
4343 - Inputs are checked against the workflow (`required`, `boolean`, `number`, `choice`, `environment`). Up to 25 inputs.
4444 - A branch-limited token can dispatch only on its branches, and never gets a trusted run.
4545 - A job can dispatch with its own token when its `permissions:` give `actions: write`.
@@ -39,7 +39,7 @@ Its cache is read-only and restores only default-branch entries.
39 ermite workflow run <file> --repo <repo> [--ref <branch|tag>] [-f name=value …]39 ermite workflow run <file> --repo <repo> [--ref <branch|tag>] [-f name=value …]
40 ```40 ```
41 41
42-- Needs `ci:write`. `<file>` is a path or a name under `.github/workflows`. `--ref` defaults to the default branch.42+- Needs `ci:write`. `<file>` is a path or a name under `.github/workflows`. `--ref` defaults to the default branch. A disabled workflow (`ermite workflow disable`) is refused with `failed_precondition`.
43 - Inputs are checked against the workflow (`required`, `boolean`, `number`, `choice`, `environment`). Up to 25 inputs.43 - Inputs are checked against the workflow (`required`, `boolean`, `number`, `choice`, `environment`). Up to 25 inputs.
44 - A branch-limited token can dispatch only on its branches, and never gets a trusted run.44 - A branch-limited token can dispatch only on its branches, and never gets a trusted run.
45 - A job can dispatch with its own token when its `permissions:` give `actions: write`.45 - A job can dispatch with its own token when its `permissions:` give `actions: write`.
M docs/ci.md +16 -0
@@ -28,6 +28,22 @@ ermite run cancel <repo> <id> # needs ci:write
2828 ermite run rerun <repo> <id> [--failed]
2929 ```
3030
31+### Disable a workflow
32+
33+```sh
34+ermite workflow list <repo> [--json]
35+ermite workflow disable <repo> <file> # needs repo:admin
36+ermite workflow enable <repo> <file>
37+```
38+
39+Like `gh workflow disable`: a disabled workflow starts no run for any event (`push`, `pull_request`, `pull_request_target`, `schedule`, `workflow_run`, `create`, `delete`, `workflow_dispatch`). Runs already started go on, and events missed while it was disabled are not replayed.
40+
41+- `<file>` is `release.yml` or `.github/workflows/release.yml`. The state belongs to the repository and the path, not to a branch, and the file need not exist: disable a release workflow right after `ermite repo create`, before the first push, so pushing tags starts nothing. The files stay identical to their GitHub copies.
42+- `list` shows the files of the default branch with their name and state, and the disabled files that branch lacks. Needs `repo:read` for a private repository.
43+- The Actions tab names the disabled workflows, and the page of a run says when its workflow is disabled.
44+- A disabled **reusable** workflow (`on: workflow_call`) can still be called with `uses:` from an enabled workflow: it then runs inside the caller's run, not as a run of its own, as on GitHub. Disable the caller to stop it.
45+- The state is checked again in the transaction that stores a run, so a disable takes effect for every run not yet stored, even one being planned at that moment.
46+
3147 The **Actions** tab of a repository shows the same. With `failing_checks_block` on (default), a merge waits for every check to pass. Turn it off with `ermite repo edit <repo> --failing-checks-block=false`.
3248
3349 ## Trust
@@ -28,6 +28,22 @@ ermite run cancel <repo> <id> # needs ci:write
28 ermite run rerun <repo> <id> [--failed]28 ermite run rerun <repo> <id> [--failed]
29 ```29 ```
30 30
31+### Disable a workflow
32+
33+```sh
34+ermite workflow list <repo> [--json]
35+ermite workflow disable <repo> <file> # needs repo:admin
36+ermite workflow enable <repo> <file>
37+```
38+
39+Like `gh workflow disable`: a disabled workflow starts no run for any event (`push`, `pull_request`, `pull_request_target`, `schedule`, `workflow_run`, `create`, `delete`, `workflow_dispatch`). Runs already started go on, and events missed while it was disabled are not replayed.
40+
41+- `<file>` is `release.yml` or `.github/workflows/release.yml`. The state belongs to the repository and the path, not to a branch, and the file need not exist: disable a release workflow right after `ermite repo create`, before the first push, so pushing tags starts nothing. The files stay identical to their GitHub copies.
42+- `list` shows the files of the default branch with their name and state, and the disabled files that branch lacks. Needs `repo:read` for a private repository.
43+- The Actions tab names the disabled workflows, and the page of a run says when its workflow is disabled.
44+- A disabled **reusable** workflow (`on: workflow_call`) can still be called with `uses:` from an enabled workflow: it then runs inside the caller's run, not as a run of its own, as on GitHub. Disable the caller to stop it.
45+- The state is checked again in the transaction that stores a run, so a disable takes effect for every run not yet stored, even one being planned at that moment.
46+
31 The **Actions** tab of a repository shows the same. With `failing_checks_block` on (default), a merge waits for every check to pass. Turn it off with `ermite repo edit <repo> --failing-checks-block=false`.47 The **Actions** tab of a repository shows the same. With `failing_checks_block` on (default), a merge waits for every check to pass. Turn it off with `ermite repo edit <repo> --failing-checks-block=false`.
32 48
33 ## Trust49 ## Trust
M docs/cli.md +3 -0
@@ -63,6 +63,9 @@ ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]
6363 ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed]
6464 ermite run artifacts <repo> <id> | download <repo> <id> [--name N]… [--dir D]
6565 ermite workflow run <file> --repo <repo> [--ref …] [-f name=value …]
66+ermite workflow list <repo> [--json]
67+ermite workflow disable <repo> <file> # no event starts a run of it; the file need not exist yet
68+ermite workflow enable <repo> <file>
6669 ermite runner list | remove <id>
6770 ermite secret set|list|delete … # CI secrets (needs ERMITE_SECRETS_KEY)
6871 ermite variable set|get|list|delete …
@@ -63,6 +63,9 @@ ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]
63 ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed]63 ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed]
64 ermite run artifacts <repo> <id> | download <repo> <id> [--name N]… [--dir D]64 ermite run artifacts <repo> <id> | download <repo> <id> [--name N]… [--dir D]
65 ermite workflow run <file> --repo <repo> [--ref …] [-f name=value …]65 ermite workflow run <file> --repo <repo> [--ref …] [-f name=value …]
66+ermite workflow list <repo> [--json]
67+ermite workflow disable <repo> <file> # no event starts a run of it; the file need not exist yet
68+ermite workflow enable <repo> <file>
66 ermite runner list | remove <id>69 ermite runner list | remove <id>
67 ermite secret set|list|delete … # CI secrets (needs ERMITE_SECRETS_KEY)70 ermite secret set|list|delete … # CI secrets (needs ERMITE_SECRETS_KEY)
68 ermite variable set|get|list|delete …71 ermite variable set|get|list|delete …
M gen/ermite/storage/ci.pb.go +89 -37
@@ -2564,6 +2564,53 @@ func (x *CIConcurrencyMark) GetQueueFull() bool {
25642564 return false
25652565 }
25662566
2567+// CIWorkflowDisabled is stored under "ciwfoff/<repo>/<workflow path>" (the path
2568+// is ".github/workflows/<file>"): the workflow is disabled and starts no run.
2569+// The file does not have to exist. It goes with the repository.
2570+type CIWorkflowDisabled struct {
2571+ state protoimpl.MessageState `protogen:"open.v1"`
2572+ DisabledAt *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=disabled_at,json=disabledAt,proto3" json:"disabled_at,omitempty"`
2573+ unknownFields protoimpl.UnknownFields
2574+ sizeCache protoimpl.SizeCache
2575+}
2576+
2577+func (x *CIWorkflowDisabled) Reset() {
2578+ *x = CIWorkflowDisabled{}
2579+ mi := &file_ermite_storage_ci_proto_msgTypes[16]
2580+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
2581+ ms.StoreMessageInfo(mi)
2582+}
2583+
2584+func (x *CIWorkflowDisabled) String() string {
2585+ return protoimpl.X.MessageStringOf(x)
2586+}
2587+
2588+func (*CIWorkflowDisabled) ProtoMessage() {}
2589+
2590+func (x *CIWorkflowDisabled) ProtoReflect() protoreflect.Message {
2591+ mi := &file_ermite_storage_ci_proto_msgTypes[16]
2592+ if x != nil {
2593+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
2594+ if ms.LoadMessageInfo() == nil {
2595+ ms.StoreMessageInfo(mi)
2596+ }
2597+ return ms
2598+ }
2599+ return mi.MessageOf(x)
2600+}
2601+
2602+// Deprecated: Use CIWorkflowDisabled.ProtoReflect.Descriptor instead.
2603+func (*CIWorkflowDisabled) Descriptor() ([]byte, []int) {
2604+ return file_ermite_storage_ci_proto_rawDescGZIP(), []int{16}
2605+}
2606+
2607+func (x *CIWorkflowDisabled) GetDisabledAt() *timestamppb.Timestamp {
2608+ if x != nil {
2609+ return x.DisabledAt
2610+ }
2611+ return nil
2612+}
2613+
25672614 var File_ermite_storage_ci_proto protoreflect.FileDescriptor
25682615
25692616 const file_ermite_storage_ci_proto_rawDesc = "" +
@@ -2808,7 +2855,10 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
28082855 "\x05group\x18\x02 \x01(\tR\x05group\x128\n" +
28092856 "\x05state\x18\x03 \x01(\x0e2\".ermite.storage.CIConcurrencyStateR\x05state\x12\x1d\n" +
28102857 "\n" +
2811- "queue_full\x18\x04 \x01(\bR\tqueueFull*\x86\x01\n" +
2858+ "queue_full\x18\x04 \x01(\bR\tqueueFull\"Q\n" +
2859+ "\x12CIWorkflowDisabled\x12;\n" +
2860+ "\vdisabled_at\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\n" +
2861+ "disabledAt*\x86\x01\n" +
28122862 "\bCIStatus\x12\x19\n" +
28132863 "\x15CI_STATUS_UNSPECIFIED\x10\x00\x12\x14\n" +
28142864 "\x10CI_STATUS_QUEUED\x10\x01\x12\x19\n" +
@@ -2871,7 +2921,7 @@ func file_ermite_storage_ci_proto_rawDescGZIP() []byte {
28712921 }
28722922
28732923 var file_ermite_storage_ci_proto_enumTypes = make([]protoimpl.EnumInfo, 8)
2874-var file_ermite_storage_ci_proto_msgTypes = make([]protoimpl.MessageInfo, 20)
2924+var file_ermite_storage_ci_proto_msgTypes = make([]protoimpl.MessageInfo, 21)
28752925 var file_ermite_storage_ci_proto_goTypes = []any{
28762926 (CIStatus)(0), // 0: ermite.storage.CIStatus
28772927 (CIConclusion)(0), // 1: ermite.storage.CIConclusion
@@ -2897,54 +2947,55 @@ var file_ermite_storage_ci_proto_goTypes = []any{
28972947 (*CIConcurrencyRecord)(nil), // 21: ermite.storage.CIConcurrencyRecord
28982948 (*CIRefTip)(nil), // 22: ermite.storage.CIRefTip
28992949 (*CIConcurrencyMark)(nil), // 23: ermite.storage.CIConcurrencyMark
2900- nil, // 24: ermite.storage.CIJob.OutputsEntry
2901- nil, // 25: ermite.storage.CIJob.PermissionsEntry
2902- nil, // 26: ermite.storage.CICall.SecretsEntry
2903- nil, // 27: ermite.storage.CIRepoToken.PermissionsEntry
2904- (*timestamppb.Timestamp)(nil), // 28: google.protobuf.Timestamp
2950+ (*CIWorkflowDisabled)(nil), // 24: ermite.storage.CIWorkflowDisabled
2951+ nil, // 25: ermite.storage.CIJob.OutputsEntry
2952+ nil, // 26: ermite.storage.CIJob.PermissionsEntry
2953+ nil, // 27: ermite.storage.CICall.SecretsEntry
2954+ nil, // 28: ermite.storage.CIRepoToken.PermissionsEntry
2955+ (*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp
29052956 }
29062957 var file_ermite_storage_ci_proto_depIdxs = []int32{
29072958 0, // 0: ermite.storage.CIRun.status:type_name -> ermite.storage.CIStatus
29082959 1, // 1: ermite.storage.CIRun.conclusion:type_name -> ermite.storage.CIConclusion
2909- 28, // 2: ermite.storage.CIRun.created_at:type_name -> google.protobuf.Timestamp
2910- 28, // 3: ermite.storage.CIRun.started_at:type_name -> google.protobuf.Timestamp
2911- 28, // 4: ermite.storage.CIRun.completed_at:type_name -> google.protobuf.Timestamp
2912- 28, // 5: ermite.storage.CIRun.cancel_requested_at:type_name -> google.protobuf.Timestamp
2960+ 29, // 2: ermite.storage.CIRun.created_at:type_name -> google.protobuf.Timestamp
2961+ 29, // 3: ermite.storage.CIRun.started_at:type_name -> google.protobuf.Timestamp
2962+ 29, // 4: ermite.storage.CIRun.completed_at:type_name -> google.protobuf.Timestamp
2963+ 29, // 5: ermite.storage.CIRun.cancel_requested_at:type_name -> google.protobuf.Timestamp
29132964 19, // 6: ermite.storage.CIRun.concurrency:type_name -> ermite.storage.CIConcurrency
29142965 2, // 7: ermite.storage.CIStep.state:type_name -> ermite.storage.CIStepState
29152966 1, // 8: ermite.storage.CIStep.conclusion:type_name -> ermite.storage.CIConclusion
2916- 28, // 9: ermite.storage.CIStep.started_at:type_name -> google.protobuf.Timestamp
2917- 28, // 10: ermite.storage.CIStep.completed_at:type_name -> google.protobuf.Timestamp
2967+ 29, // 9: ermite.storage.CIStep.started_at:type_name -> google.protobuf.Timestamp
2968+ 29, // 10: ermite.storage.CIStep.completed_at:type_name -> google.protobuf.Timestamp
29182969 1, // 11: ermite.storage.CIStep.outcome:type_name -> ermite.storage.CIConclusion
29192970 3, // 12: ermite.storage.CIJob.phase:type_name -> ermite.storage.CIJobPhase
29202971 1, // 13: ermite.storage.CIJob.conclusion:type_name -> ermite.storage.CIConclusion
29212972 4, // 14: ermite.storage.CIJob.isolation:type_name -> ermite.storage.CIIsolation
29222973 10, // 15: ermite.storage.CIJob.steps:type_name -> ermite.storage.CIStep
2923- 28, // 16: ermite.storage.CIJob.created_at:type_name -> google.protobuf.Timestamp
2924- 28, // 17: ermite.storage.CIJob.started_at:type_name -> google.protobuf.Timestamp
2925- 28, // 18: ermite.storage.CIJob.completed_at:type_name -> google.protobuf.Timestamp
2926- 24, // 19: ermite.storage.CIJob.outputs:type_name -> ermite.storage.CIJob.OutputsEntry
2927- 28, // 20: ermite.storage.CIJob.ready_at:type_name -> google.protobuf.Timestamp
2928- 28, // 21: ermite.storage.CIJob.assigned_at:type_name -> google.protobuf.Timestamp
2929- 28, // 22: ermite.storage.CIJob.acked_at:type_name -> google.protobuf.Timestamp
2930- 28, // 23: ermite.storage.CIJob.token_valid_until:type_name -> google.protobuf.Timestamp
2974+ 29, // 16: ermite.storage.CIJob.created_at:type_name -> google.protobuf.Timestamp
2975+ 29, // 17: ermite.storage.CIJob.started_at:type_name -> google.protobuf.Timestamp
2976+ 29, // 18: ermite.storage.CIJob.completed_at:type_name -> google.protobuf.Timestamp
2977+ 25, // 19: ermite.storage.CIJob.outputs:type_name -> ermite.storage.CIJob.OutputsEntry
2978+ 29, // 20: ermite.storage.CIJob.ready_at:type_name -> google.protobuf.Timestamp
2979+ 29, // 21: ermite.storage.CIJob.assigned_at:type_name -> google.protobuf.Timestamp
2980+ 29, // 22: ermite.storage.CIJob.acked_at:type_name -> google.protobuf.Timestamp
2981+ 29, // 23: ermite.storage.CIJob.token_valid_until:type_name -> google.protobuf.Timestamp
29312982 5, // 24: ermite.storage.CIJob.cancel_reason:type_name -> ermite.storage.CICancelReason
2932- 28, // 25: ermite.storage.CIJob.cancel_requested_at:type_name -> google.protobuf.Timestamp
2933- 25, // 26: ermite.storage.CIJob.permissions:type_name -> ermite.storage.CIJob.PermissionsEntry
2983+ 29, // 25: ermite.storage.CIJob.cancel_requested_at:type_name -> google.protobuf.Timestamp
2984+ 26, // 26: ermite.storage.CIJob.permissions:type_name -> ermite.storage.CIJob.PermissionsEntry
29342985 19, // 27: ermite.storage.CIJob.concurrency:type_name -> ermite.storage.CIConcurrency
29352986 12, // 28: ermite.storage.CIJob.call:type_name -> ermite.storage.CICall
2936- 26, // 29: ermite.storage.CICall.secrets:type_name -> ermite.storage.CICall.SecretsEntry
2987+ 27, // 29: ermite.storage.CICall.secrets:type_name -> ermite.storage.CICall.SecretsEntry
29372988 19, // 30: ermite.storage.CICall.concurrency:type_name -> ermite.storage.CIConcurrency
29382989 0, // 31: ermite.storage.CICheck.status:type_name -> ermite.storage.CIStatus
29392990 1, // 32: ermite.storage.CICheck.conclusion:type_name -> ermite.storage.CIConclusion
2940- 28, // 33: ermite.storage.CICheck.created_at:type_name -> google.protobuf.Timestamp
2941- 28, // 34: ermite.storage.CICheck.started_at:type_name -> google.protobuf.Timestamp
2942- 28, // 35: ermite.storage.CICheck.completed_at:type_name -> google.protobuf.Timestamp
2991+ 29, // 33: ermite.storage.CICheck.created_at:type_name -> google.protobuf.Timestamp
2992+ 29, // 34: ermite.storage.CICheck.started_at:type_name -> google.protobuf.Timestamp
2993+ 29, // 35: ermite.storage.CICheck.completed_at:type_name -> google.protobuf.Timestamp
29432994 4, // 36: ermite.storage.CIRunner.isolation:type_name -> ermite.storage.CIIsolation
2944- 28, // 37: ermite.storage.CIRunner.registered_at:type_name -> google.protobuf.Timestamp
2945- 28, // 38: ermite.storage.CIRepoToken.expires_at:type_name -> google.protobuf.Timestamp
2946- 28, // 39: ermite.storage.CIRepoToken.created_at:type_name -> google.protobuf.Timestamp
2947- 27, // 40: ermite.storage.CIRepoToken.permissions:type_name -> ermite.storage.CIRepoToken.PermissionsEntry
2995+ 29, // 37: ermite.storage.CIRunner.registered_at:type_name -> google.protobuf.Timestamp
2996+ 29, // 38: ermite.storage.CIRepoToken.expires_at:type_name -> google.protobuf.Timestamp
2997+ 29, // 39: ermite.storage.CIRepoToken.created_at:type_name -> google.protobuf.Timestamp
2998+ 28, // 40: ermite.storage.CIRepoToken.permissions:type_name -> ermite.storage.CIRepoToken.PermissionsEntry
29482999 6, // 41: ermite.storage.CIAnnotation.level:type_name -> ermite.storage.CIAnnotationLevel
29493000 17, // 42: ermite.storage.CIStepAnnotations.annotations:type_name -> ermite.storage.CIAnnotation
29503001 7, // 43: ermite.storage.CIConcurrency.state:type_name -> ermite.storage.CIConcurrencyState
@@ -2953,11 +3004,12 @@ var file_ermite_storage_ci_proto_depIdxs = []int32{
29533004 20, // 46: ermite.storage.CIConcurrencyRecord.queue:type_name -> ermite.storage.CIConcurrencyEntry
29543005 20, // 47: ermite.storage.CIConcurrencyMark.entry:type_name -> ermite.storage.CIConcurrencyEntry
29553006 7, // 48: ermite.storage.CIConcurrencyMark.state:type_name -> ermite.storage.CIConcurrencyState
2956- 49, // [49:49] is the sub-list for method output_type
2957- 49, // [49:49] is the sub-list for method input_type
2958- 49, // [49:49] is the sub-list for extension type_name
2959- 49, // [49:49] is the sub-list for extension extendee
2960- 0, // [0:49] is the sub-list for field type_name
3007+ 29, // 49: ermite.storage.CIWorkflowDisabled.disabled_at:type_name -> google.protobuf.Timestamp
3008+ 50, // [50:50] is the sub-list for method output_type
3009+ 50, // [50:50] is the sub-list for method input_type
3010+ 50, // [50:50] is the sub-list for extension type_name
3011+ 50, // [50:50] is the sub-list for extension extendee
3012+ 0, // [0:50] is the sub-list for field type_name
29613013 }
29623014
29633015 func init() { file_ermite_storage_ci_proto_init() }
@@ -2971,7 +3023,7 @@ func file_ermite_storage_ci_proto_init() {
29713023 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
29723024 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_ci_proto_rawDesc), len(file_ermite_storage_ci_proto_rawDesc)),
29733025 NumEnums: 8,
2974- NumMessages: 20,
3026+ NumMessages: 21,
29753027 NumExtensions: 0,
29763028 NumServices: 0,
29773029 },
@@ -2564,6 +2564,53 @@ func (x *CIConcurrencyMark) GetQueueFull() bool {
2564 return false2564 return false
2565 }2565 }
2566 2566
2567+// CIWorkflowDisabled is stored under "ciwfoff/<repo>/<workflow path>" (the path
2568+// is ".github/workflows/<file>"): the workflow is disabled and starts no run.
2569+// The file does not have to exist. It goes with the repository.
2570+type CIWorkflowDisabled struct {
2571+ state protoimpl.MessageState `protogen:"open.v1"`
2572+ DisabledAt *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=disabled_at,json=disabledAt,proto3" json:"disabled_at,omitempty"`
2573+ unknownFields protoimpl.UnknownFields
2574+ sizeCache protoimpl.SizeCache
2575+}
2576+
2577+func (x *CIWorkflowDisabled) Reset() {
2578+ *x = CIWorkflowDisabled{}
2579+ mi := &file_ermite_storage_ci_proto_msgTypes[16]
2580+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
2581+ ms.StoreMessageInfo(mi)
2582+}
2583+
2584+func (x *CIWorkflowDisabled) String() string {
2585+ return protoimpl.X.MessageStringOf(x)
2586+}
2587+
2588+func (*CIWorkflowDisabled) ProtoMessage() {}
2589+
2590+func (x *CIWorkflowDisabled) ProtoReflect() protoreflect.Message {
2591+ mi := &file_ermite_storage_ci_proto_msgTypes[16]
2592+ if x != nil {
2593+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
2594+ if ms.LoadMessageInfo() == nil {
2595+ ms.StoreMessageInfo(mi)
2596+ }
2597+ return ms
2598+ }
2599+ return mi.MessageOf(x)
2600+}
2601+
2602+// Deprecated: Use CIWorkflowDisabled.ProtoReflect.Descriptor instead.
2603+func (*CIWorkflowDisabled) Descriptor() ([]byte, []int) {
2604+ return file_ermite_storage_ci_proto_rawDescGZIP(), []int{16}
2605+}
2606+
2607+func (x *CIWorkflowDisabled) GetDisabledAt() *timestamppb.Timestamp {
2608+ if x != nil {
2609+ return x.DisabledAt
2610+ }
2611+ return nil
2612+}
2613+
2567 var File_ermite_storage_ci_proto protoreflect.FileDescriptor2614 var File_ermite_storage_ci_proto protoreflect.FileDescriptor
2568 2615
2569 const file_ermite_storage_ci_proto_rawDesc = "" +2616 const file_ermite_storage_ci_proto_rawDesc = "" +
@@ -2808,7 +2855,10 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
2808 "\x05group\x18\x02 \x01(\tR\x05group\x128\n" +2855 "\x05group\x18\x02 \x01(\tR\x05group\x128\n" +
2809 "\x05state\x18\x03 \x01(\x0e2\".ermite.storage.CIConcurrencyStateR\x05state\x12\x1d\n" +2856 "\x05state\x18\x03 \x01(\x0e2\".ermite.storage.CIConcurrencyStateR\x05state\x12\x1d\n" +
2810 "\n" +2857 "\n" +
2811- "queue_full\x18\x04 \x01(\bR\tqueueFull*\x86\x01\n" +2858+ "queue_full\x18\x04 \x01(\bR\tqueueFull\"Q\n" +
2859+ "\x12CIWorkflowDisabled\x12;\n" +
2860+ "\vdisabled_at\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\n" +
2861+ "disabledAt*\x86\x01\n" +
2812 "\bCIStatus\x12\x19\n" +2862 "\bCIStatus\x12\x19\n" +
2813 "\x15CI_STATUS_UNSPECIFIED\x10\x00\x12\x14\n" +2863 "\x15CI_STATUS_UNSPECIFIED\x10\x00\x12\x14\n" +
2814 "\x10CI_STATUS_QUEUED\x10\x01\x12\x19\n" +2864 "\x10CI_STATUS_QUEUED\x10\x01\x12\x19\n" +
@@ -2871,7 +2921,7 @@ func file_ermite_storage_ci_proto_rawDescGZIP() []byte {
2871 }2921 }
2872 2922
2873 var file_ermite_storage_ci_proto_enumTypes = make([]protoimpl.EnumInfo, 8)2923 var file_ermite_storage_ci_proto_enumTypes = make([]protoimpl.EnumInfo, 8)
2874-var file_ermite_storage_ci_proto_msgTypes = make([]protoimpl.MessageInfo, 20)2924+var file_ermite_storage_ci_proto_msgTypes = make([]protoimpl.MessageInfo, 21)
2875 var file_ermite_storage_ci_proto_goTypes = []any{2925 var file_ermite_storage_ci_proto_goTypes = []any{
2876 (CIStatus)(0), // 0: ermite.storage.CIStatus2926 (CIStatus)(0), // 0: ermite.storage.CIStatus
2877 (CIConclusion)(0), // 1: ermite.storage.CIConclusion2927 (CIConclusion)(0), // 1: ermite.storage.CIConclusion
@@ -2897,54 +2947,55 @@ var file_ermite_storage_ci_proto_goTypes = []any{
2897 (*CIConcurrencyRecord)(nil), // 21: ermite.storage.CIConcurrencyRecord2947 (*CIConcurrencyRecord)(nil), // 21: ermite.storage.CIConcurrencyRecord
2898 (*CIRefTip)(nil), // 22: ermite.storage.CIRefTip2948 (*CIRefTip)(nil), // 22: ermite.storage.CIRefTip
2899 (*CIConcurrencyMark)(nil), // 23: ermite.storage.CIConcurrencyMark2949 (*CIConcurrencyMark)(nil), // 23: ermite.storage.CIConcurrencyMark
2900- nil, // 24: ermite.storage.CIJob.OutputsEntry2950+ (*CIWorkflowDisabled)(nil), // 24: ermite.storage.CIWorkflowDisabled
2901- nil, // 25: ermite.storage.CIJob.PermissionsEntry2951+ nil, // 25: ermite.storage.CIJob.OutputsEntry
2902- nil, // 26: ermite.storage.CICall.SecretsEntry2952+ nil, // 26: ermite.storage.CIJob.PermissionsEntry
2903- nil, // 27: ermite.storage.CIRepoToken.PermissionsEntry2953+ nil, // 27: ermite.storage.CICall.SecretsEntry
2904- (*timestamppb.Timestamp)(nil), // 28: google.protobuf.Timestamp2954+ nil, // 28: ermite.storage.CIRepoToken.PermissionsEntry
2955+ (*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp
2905 }2956 }
2906 var file_ermite_storage_ci_proto_depIdxs = []int32{2957 var file_ermite_storage_ci_proto_depIdxs = []int32{
2907 0, // 0: ermite.storage.CIRun.status:type_name -> ermite.storage.CIStatus2958 0, // 0: ermite.storage.CIRun.status:type_name -> ermite.storage.CIStatus
2908 1, // 1: ermite.storage.CIRun.conclusion:type_name -> ermite.storage.CIConclusion2959 1, // 1: ermite.storage.CIRun.conclusion:type_name -> ermite.storage.CIConclusion
2909- 28, // 2: ermite.storage.CIRun.created_at:type_name -> google.protobuf.Timestamp2960+ 29, // 2: ermite.storage.CIRun.created_at:type_name -> google.protobuf.Timestamp
2910- 28, // 3: ermite.storage.CIRun.started_at:type_name -> google.protobuf.Timestamp2961+ 29, // 3: ermite.storage.CIRun.started_at:type_name -> google.protobuf.Timestamp
2911- 28, // 4: ermite.storage.CIRun.completed_at:type_name -> google.protobuf.Timestamp2962+ 29, // 4: ermite.storage.CIRun.completed_at:type_name -> google.protobuf.Timestamp
2912- 28, // 5: ermite.storage.CIRun.cancel_requested_at:type_name -> google.protobuf.Timestamp2963+ 29, // 5: ermite.storage.CIRun.cancel_requested_at:type_name -> google.protobuf.Timestamp
2913 19, // 6: ermite.storage.CIRun.concurrency:type_name -> ermite.storage.CIConcurrency2964 19, // 6: ermite.storage.CIRun.concurrency:type_name -> ermite.storage.CIConcurrency
2914 2, // 7: ermite.storage.CIStep.state:type_name -> ermite.storage.CIStepState2965 2, // 7: ermite.storage.CIStep.state:type_name -> ermite.storage.CIStepState
2915 1, // 8: ermite.storage.CIStep.conclusion:type_name -> ermite.storage.CIConclusion2966 1, // 8: ermite.storage.CIStep.conclusion:type_name -> ermite.storage.CIConclusion
2916- 28, // 9: ermite.storage.CIStep.started_at:type_name -> google.protobuf.Timestamp2967+ 29, // 9: ermite.storage.CIStep.started_at:type_name -> google.protobuf.Timestamp
2917- 28, // 10: ermite.storage.CIStep.completed_at:type_name -> google.protobuf.Timestamp2968+ 29, // 10: ermite.storage.CIStep.completed_at:type_name -> google.protobuf.Timestamp
2918 1, // 11: ermite.storage.CIStep.outcome:type_name -> ermite.storage.CIConclusion2969 1, // 11: ermite.storage.CIStep.outcome:type_name -> ermite.storage.CIConclusion
2919 3, // 12: ermite.storage.CIJob.phase:type_name -> ermite.storage.CIJobPhase2970 3, // 12: ermite.storage.CIJob.phase:type_name -> ermite.storage.CIJobPhase
2920 1, // 13: ermite.storage.CIJob.conclusion:type_name -> ermite.storage.CIConclusion2971 1, // 13: ermite.storage.CIJob.conclusion:type_name -> ermite.storage.CIConclusion
2921 4, // 14: ermite.storage.CIJob.isolation:type_name -> ermite.storage.CIIsolation2972 4, // 14: ermite.storage.CIJob.isolation:type_name -> ermite.storage.CIIsolation
2922 10, // 15: ermite.storage.CIJob.steps:type_name -> ermite.storage.CIStep2973 10, // 15: ermite.storage.CIJob.steps:type_name -> ermite.storage.CIStep
2923- 28, // 16: ermite.storage.CIJob.created_at:type_name -> google.protobuf.Timestamp2974+ 29, // 16: ermite.storage.CIJob.created_at:type_name -> google.protobuf.Timestamp
2924- 28, // 17: ermite.storage.CIJob.started_at:type_name -> google.protobuf.Timestamp2975+ 29, // 17: ermite.storage.CIJob.started_at:type_name -> google.protobuf.Timestamp
2925- 28, // 18: ermite.storage.CIJob.completed_at:type_name -> google.protobuf.Timestamp2976+ 29, // 18: ermite.storage.CIJob.completed_at:type_name -> google.protobuf.Timestamp
2926- 24, // 19: ermite.storage.CIJob.outputs:type_name -> ermite.storage.CIJob.OutputsEntry2977+ 25, // 19: ermite.storage.CIJob.outputs:type_name -> ermite.storage.CIJob.OutputsEntry
2927- 28, // 20: ermite.storage.CIJob.ready_at:type_name -> google.protobuf.Timestamp2978+ 29, // 20: ermite.storage.CIJob.ready_at:type_name -> google.protobuf.Timestamp
2928- 28, // 21: ermite.storage.CIJob.assigned_at:type_name -> google.protobuf.Timestamp2979+ 29, // 21: ermite.storage.CIJob.assigned_at:type_name -> google.protobuf.Timestamp
2929- 28, // 22: ermite.storage.CIJob.acked_at:type_name -> google.protobuf.Timestamp2980+ 29, // 22: ermite.storage.CIJob.acked_at:type_name -> google.protobuf.Timestamp
2930- 28, // 23: ermite.storage.CIJob.token_valid_until:type_name -> google.protobuf.Timestamp2981+ 29, // 23: ermite.storage.CIJob.token_valid_until:type_name -> google.protobuf.Timestamp
2931 5, // 24: ermite.storage.CIJob.cancel_reason:type_name -> ermite.storage.CICancelReason2982 5, // 24: ermite.storage.CIJob.cancel_reason:type_name -> ermite.storage.CICancelReason
2932- 28, // 25: ermite.storage.CIJob.cancel_requested_at:type_name -> google.protobuf.Timestamp2983+ 29, // 25: ermite.storage.CIJob.cancel_requested_at:type_name -> google.protobuf.Timestamp
2933- 25, // 26: ermite.storage.CIJob.permissions:type_name -> ermite.storage.CIJob.PermissionsEntry2984+ 26, // 26: ermite.storage.CIJob.permissions:type_name -> ermite.storage.CIJob.PermissionsEntry
2934 19, // 27: ermite.storage.CIJob.concurrency:type_name -> ermite.storage.CIConcurrency2985 19, // 27: ermite.storage.CIJob.concurrency:type_name -> ermite.storage.CIConcurrency
2935 12, // 28: ermite.storage.CIJob.call:type_name -> ermite.storage.CICall2986 12, // 28: ermite.storage.CIJob.call:type_name -> ermite.storage.CICall
2936- 26, // 29: ermite.storage.CICall.secrets:type_name -> ermite.storage.CICall.SecretsEntry2987+ 27, // 29: ermite.storage.CICall.secrets:type_name -> ermite.storage.CICall.SecretsEntry
2937 19, // 30: ermite.storage.CICall.concurrency:type_name -> ermite.storage.CIConcurrency2988 19, // 30: ermite.storage.CICall.concurrency:type_name -> ermite.storage.CIConcurrency
2938 0, // 31: ermite.storage.CICheck.status:type_name -> ermite.storage.CIStatus2989 0, // 31: ermite.storage.CICheck.status:type_name -> ermite.storage.CIStatus
2939 1, // 32: ermite.storage.CICheck.conclusion:type_name -> ermite.storage.CIConclusion2990 1, // 32: ermite.storage.CICheck.conclusion:type_name -> ermite.storage.CIConclusion
2940- 28, // 33: ermite.storage.CICheck.created_at:type_name -> google.protobuf.Timestamp2991+ 29, // 33: ermite.storage.CICheck.created_at:type_name -> google.protobuf.Timestamp
2941- 28, // 34: ermite.storage.CICheck.started_at:type_name -> google.protobuf.Timestamp2992+ 29, // 34: ermite.storage.CICheck.started_at:type_name -> google.protobuf.Timestamp
2942- 28, // 35: ermite.storage.CICheck.completed_at:type_name -> google.protobuf.Timestamp2993+ 29, // 35: ermite.storage.CICheck.completed_at:type_name -> google.protobuf.Timestamp
2943 4, // 36: ermite.storage.CIRunner.isolation:type_name -> ermite.storage.CIIsolation2994 4, // 36: ermite.storage.CIRunner.isolation:type_name -> ermite.storage.CIIsolation
2944- 28, // 37: ermite.storage.CIRunner.registered_at:type_name -> google.protobuf.Timestamp2995+ 29, // 37: ermite.storage.CIRunner.registered_at:type_name -> google.protobuf.Timestamp
2945- 28, // 38: ermite.storage.CIRepoToken.expires_at:type_name -> google.protobuf.Timestamp2996+ 29, // 38: ermite.storage.CIRepoToken.expires_at:type_name -> google.protobuf.Timestamp
2946- 28, // 39: ermite.storage.CIRepoToken.created_at:type_name -> google.protobuf.Timestamp2997+ 29, // 39: ermite.storage.CIRepoToken.created_at:type_name -> google.protobuf.Timestamp
2947- 27, // 40: ermite.storage.CIRepoToken.permissions:type_name -> ermite.storage.CIRepoToken.PermissionsEntry2998+ 28, // 40: ermite.storage.CIRepoToken.permissions:type_name -> ermite.storage.CIRepoToken.PermissionsEntry
2948 6, // 41: ermite.storage.CIAnnotation.level:type_name -> ermite.storage.CIAnnotationLevel2999 6, // 41: ermite.storage.CIAnnotation.level:type_name -> ermite.storage.CIAnnotationLevel
2949 17, // 42: ermite.storage.CIStepAnnotations.annotations:type_name -> ermite.storage.CIAnnotation3000 17, // 42: ermite.storage.CIStepAnnotations.annotations:type_name -> ermite.storage.CIAnnotation
2950 7, // 43: ermite.storage.CIConcurrency.state:type_name -> ermite.storage.CIConcurrencyState3001 7, // 43: ermite.storage.CIConcurrency.state:type_name -> ermite.storage.CIConcurrencyState
@@ -2953,11 +3004,12 @@ var file_ermite_storage_ci_proto_depIdxs = []int32{
2953 20, // 46: ermite.storage.CIConcurrencyRecord.queue:type_name -> ermite.storage.CIConcurrencyEntry3004 20, // 46: ermite.storage.CIConcurrencyRecord.queue:type_name -> ermite.storage.CIConcurrencyEntry
2954 20, // 47: ermite.storage.CIConcurrencyMark.entry:type_name -> ermite.storage.CIConcurrencyEntry3005 20, // 47: ermite.storage.CIConcurrencyMark.entry:type_name -> ermite.storage.CIConcurrencyEntry
2955 7, // 48: ermite.storage.CIConcurrencyMark.state:type_name -> ermite.storage.CIConcurrencyState3006 7, // 48: ermite.storage.CIConcurrencyMark.state:type_name -> ermite.storage.CIConcurrencyState
2956- 49, // [49:49] is the sub-list for method output_type3007+ 29, // 49: ermite.storage.CIWorkflowDisabled.disabled_at:type_name -> google.protobuf.Timestamp
2957- 49, // [49:49] is the sub-list for method input_type3008+ 50, // [50:50] is the sub-list for method output_type
2958- 49, // [49:49] is the sub-list for extension type_name3009+ 50, // [50:50] is the sub-list for method input_type
2959- 49, // [49:49] is the sub-list for extension extendee3010+ 50, // [50:50] is the sub-list for extension type_name
2960- 0, // [0:49] is the sub-list for field type_name3011+ 50, // [50:50] is the sub-list for extension extendee
3012+ 0, // [0:50] is the sub-list for field type_name
2961 }3013 }
2962 3014
2963 func init() { file_ermite_storage_ci_proto_init() }3015 func init() { file_ermite_storage_ci_proto_init() }
@@ -2971,7 +3023,7 @@ func file_ermite_storage_ci_proto_init() {
2971 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),3023 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
2972 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_ci_proto_rawDesc), len(file_ermite_storage_ci_proto_rawDesc)),3024 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_ci_proto_rawDesc), len(file_ermite_storage_ci_proto_rawDesc)),
2973 NumEnums: 8,3025 NumEnums: 8,
2974- NumMessages: 20,3026+ NumMessages: 21,
2975 NumExtensions: 0,3027 NumExtensions: 0,
2976 NumServices: 0,3028 NumServices: 0,
2977 },3029 },
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.6.0
16+ bnema.dev/ermite-api v0.7.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.6.016+ bnema.dev/ermite-api v0.7.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.1.018 bnema.dev/ermite-kv v0.1.0
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
M go.sum +2 -2 generated file: collapsed
M internal/adapters/client/client_test.go +3 -3
@@ -139,16 +139,16 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139139 func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIArtifacts, client.FeatureCommitSignatures},
142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
146- client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch,
146+ client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,
147147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures,
148148 }))
149149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150150 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession,
151- client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIArtifacts, client.FeatureCommitSignatures,
151+ client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures,
152152 }))
153153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
154154 }
@@ -139,16 +139,16 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139 func TestMissingFeatures(t *testing.T) {139 func TestMissingFeatures(t *testing.T) {
140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141 assert.Equal(t,141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIArtifacts, client.FeatureCommitSignatures},142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures},
143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
146- client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch,146+ client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,
147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures,147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures,
148 }))148 }))
149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession,150 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession,
151- client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIArtifacts, client.FeatureCommitSignatures,151+ client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures,
152 }))152 }))
153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
154 }154 }
M internal/adapters/client/features.go +4 -1
@@ -42,6 +42,9 @@ const (
4242 FeatureCISecrets = "ci.secrets"
4343 // FeatureCIDispatch is RunService.DispatchWorkflow (`ermite workflow run`).
4444 FeatureCIDispatch = "ci.dispatch"
45+ // FeatureCIWorkflows is RunService.ListWorkflows, DisableWorkflow and
46+ // EnableWorkflow (`ermite workflow list|disable|enable`).
47+ FeatureCIWorkflows = "ci.workflows"
4548 // FeatureCIArtifacts is ArtifactService (`ermite run artifacts`,
4649 // `run download`).
4750 FeatureCIArtifacts = "ci.artifacts"
@@ -52,7 +55,7 @@ const (
5255
5356 // UsedFeatures lists the features the CLI uses, in the order they are reported.
5457 func UsedFeatures() []string {
55- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}
58+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}
5659 }
5760
5861 // MissingFeatures returns the features the CLI uses that have is lacking.
@@ -42,6 +42,9 @@ const (
42 FeatureCISecrets = "ci.secrets"42 FeatureCISecrets = "ci.secrets"
43 // FeatureCIDispatch is RunService.DispatchWorkflow (`ermite workflow run`).43 // FeatureCIDispatch is RunService.DispatchWorkflow (`ermite workflow run`).
44 FeatureCIDispatch = "ci.dispatch"44 FeatureCIDispatch = "ci.dispatch"
45+ // FeatureCIWorkflows is RunService.ListWorkflows, DisableWorkflow and
46+ // EnableWorkflow (`ermite workflow list|disable|enable`).
47+ FeatureCIWorkflows = "ci.workflows"
45 // FeatureCIArtifacts is ArtifactService (`ermite run artifacts`,48 // FeatureCIArtifacts is ArtifactService (`ermite run artifacts`,
46 // `run download`).49 // `run download`).
47 FeatureCIArtifacts = "ci.artifacts"50 FeatureCIArtifacts = "ci.artifacts"
@@ -52,7 +55,7 @@ const (
52 55
53 // UsedFeatures lists the features the CLI uses, in the order they are reported.56 // UsedFeatures lists the features the CLI uses, in the order they are reported.
54 func UsedFeatures() []string {57 func UsedFeatures() []string {
55- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}58+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}
56 }59 }
57 60
58 // MissingFeatures returns the features the CLI uses that have is lacking.61 // MissingFeatures returns the features the CLI uses that have is lacking.
M internal/adapters/kvstore/ci.go +9 -1
@@ -580,7 +580,14 @@ const createRunTries = 20
580580 //
581581 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next numbers under version preconditions: a storage concern, no policy
582582 func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) {
583+ offKey := workflowOffKey(repo, workflowPath)
583584 for range createRunTries {
585+ // A workflow the repository disabled gets no run, whatever started it.
586+ // The key is also required absent by the batch below, so a disable that
587+ // lands after this read fails the batch, and the next try sees it.
588+ if _, off := c.db.Get(ctx, offKey); off {
589+ return ports.CIRunDraft{}, domain.ErrWorkflowDisabled
590+ }
584591 lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey))
585592 if err != nil {
586593 return ports.CIRunDraft{}, err
@@ -605,6 +612,7 @@ func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath s
605612 })
606613 b.RequireAbsent(runSrcKey(draft.Run.ID))
607614 b.Put(runSrcKey(draft.Run.ID), src)
615+ b.RequireAbsent(offKey)
608616 for r := range guarded {
609617 if err := c.guardRepo(ctx, &b, r); err != nil {
610618 return ports.CIRunDraft{}, err
@@ -887,7 +895,7 @@ func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]by
887895 }
888896 keys = append(keys, repoReaderKeys(ctx, snap, repo)...)
889897 keys = append(keys, artifactKeys(ctx, snap, repo)...)
890- for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/", concurPrefix + repo.String() + "/", concurMarkPrefixAll + repo.String() + "/", refTipsPrefix(repo)} {
898+ for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/", concurPrefix + repo.String() + "/", concurMarkPrefixAll + repo.String() + "/", refTipsPrefix(repo), workflowOffPrefixOf(repo)} {
891899 for k := range snap.Prefix(ctx, []byte(p)) {
892900 keys = append(keys, slices.Clone(k))
893901 }
@@ -580,7 +580,14 @@ const createRunTries = 20
580 //580 //
581 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next numbers under version preconditions: a storage concern, no policy581 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next numbers under version preconditions: a storage concern, no policy
582 func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) {582 func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) {
583+ offKey := workflowOffKey(repo, workflowPath)
583 for range createRunTries {584 for range createRunTries {
585+ // A workflow the repository disabled gets no run, whatever started it.
586+ // The key is also required absent by the batch below, so a disable that
587+ // lands after this read fails the batch, and the next try sees it.
588+ if _, off := c.db.Get(ctx, offKey); off {
589+ return ports.CIRunDraft{}, domain.ErrWorkflowDisabled
590+ }
584 lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey))591 lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey))
585 if err != nil {592 if err != nil {
586 return ports.CIRunDraft{}, err593 return ports.CIRunDraft{}, err
@@ -605,6 +612,7 @@ func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath s
605 })612 })
606 b.RequireAbsent(runSrcKey(draft.Run.ID))613 b.RequireAbsent(runSrcKey(draft.Run.ID))
607 b.Put(runSrcKey(draft.Run.ID), src)614 b.Put(runSrcKey(draft.Run.ID), src)
615+ b.RequireAbsent(offKey)
608 for r := range guarded {616 for r := range guarded {
609 if err := c.guardRepo(ctx, &b, r); err != nil {617 if err := c.guardRepo(ctx, &b, r); err != nil {
610 return ports.CIRunDraft{}, err618 return ports.CIRunDraft{}, err
@@ -887,7 +895,7 @@ func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]by
887 }895 }
888 keys = append(keys, repoReaderKeys(ctx, snap, repo)...)896 keys = append(keys, repoReaderKeys(ctx, snap, repo)...)
889 keys = append(keys, artifactKeys(ctx, snap, repo)...)897 keys = append(keys, artifactKeys(ctx, snap, repo)...)
890- for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/", concurPrefix + repo.String() + "/", concurMarkPrefixAll + repo.String() + "/", refTipsPrefix(repo)} {898+ for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/", concurPrefix + repo.String() + "/", concurMarkPrefixAll + repo.String() + "/", refTipsPrefix(repo), workflowOffPrefixOf(repo)} {
891 for k := range snap.Prefix(ctx, []byte(p)) {899 for k := range snap.Prefix(ctx, []byte(p)) {
892 keys = append(keys, slices.Clone(k))900 keys = append(keys, slices.Clone(k))
893 }901 }
A internal/adapters/kvstore/ci_workflow.go +91 -0
@@ -0,0 +1,91 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "sort"
10+ "strings"
11+ "time"
12+
13+ kv "bnema.dev/ermite-kv"
14+ "google.golang.org/protobuf/proto"
15+ "google.golang.org/protobuf/types/known/timestamppb"
16+
17+ storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+// The workflows a repository disabled (`ermite workflow disable`):
23+//
24+// ciwfoff/<repo>/<workflow path> CIWorkflowDisabled
25+//
26+// <workflow path> is ".github/workflows/<file>", whether or not the file exists.
27+// Written under the guard of the repository key, deleted with the repository
28+// (ciKeys).
29+const workflowOffPrefix = "ciwfoff/"
30+
31+var _ ports.CIWorkflowStore = (*CI)(nil)
32+
33+func workflowOffKey(repo domain.RepoName, path string) []byte {
34+ return []byte(workflowOffPrefix + repo.String() + "/" + path)
35+}
36+
37+func workflowOffPrefixOf(repo domain.RepoName) string { return workflowOffPrefix + repo.String() + "/" }
38+
39+// DisableWorkflow records the path as disabled. One already disabled keeps its
40+// date. A repository that does not exist is domain.ErrNotFound.
41+func (c *CI) DisableWorkflow(ctx context.Context, repo domain.RepoName, path string, at time.Time) error {
42+ val, err := proto.Marshal(&storagepb.CIWorkflowDisabled{DisabledAt: timestamppb.New(at)})
43+ if err != nil {
44+ return fmt.Errorf("encode disabled workflow: %w", err)
45+ }
46+ key := workflowOffKey(repo, path)
47+ for range updateAttemptsTip {
48+ if _, ok := c.db.Get(ctx, key); ok {
49+ if _, ok := c.db.Get(ctx, repoKey(repo)); !ok {
50+ return domain.ErrNotFound
51+ }
52+ return nil
53+ }
54+ var b kv.Batch
55+ if err := c.guardRepo(ctx, &b, repo); err != nil {
56+ return err
57+ }
58+ b.RequireAbsent(key)
59+ b.Put(key, val)
60+ _, err := c.db.Apply(ctx, &b)
61+ if errors.Is(err, kv.ErrConflict) {
62+ continue
63+ }
64+ if err != nil {
65+ return fmt.Errorf("store disabled workflow: %w", err)
66+ }
67+ return nil
68+ }
69+ return fmt.Errorf("store disabled workflow: %w", domain.ErrConflict)
70+}
71+
72+// EnableWorkflow removes the record of the path.
73+func (c *CI) EnableWorkflow(ctx context.Context, repo domain.RepoName, path string) error {
74+ var b kv.Batch
75+ b.Delete(workflowOffKey(repo, path))
76+ if _, err := c.db.Apply(ctx, &b); err != nil {
77+ return fmt.Errorf("delete disabled workflow: %w", err)
78+ }
79+ return nil
80+}
81+
82+// DisabledWorkflows returns the disabled paths of the repository, sorted.
83+func (c *CI) DisabledWorkflows(ctx context.Context, repo domain.RepoName) ([]string, error) {
84+ prefix := workflowOffPrefixOf(repo)
85+ var out []string
86+ for k := range c.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
87+ out = append(out, strings.TrimPrefix(string(k), prefix))
88+ }
89+ sort.Strings(out)
90+ return out, nil
91+}
@@ -0,0 +1,91 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "sort"
10+ "strings"
11+ "time"
12+
13+ kv "bnema.dev/ermite-kv"
14+ "google.golang.org/protobuf/proto"
15+ "google.golang.org/protobuf/types/known/timestamppb"
16+
17+ storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+// The workflows a repository disabled (`ermite workflow disable`):
23+//
24+// ciwfoff/<repo>/<workflow path> CIWorkflowDisabled
25+//
26+// <workflow path> is ".github/workflows/<file>", whether or not the file exists.
27+// Written under the guard of the repository key, deleted with the repository
28+// (ciKeys).
29+const workflowOffPrefix = "ciwfoff/"
30+
31+var _ ports.CIWorkflowStore = (*CI)(nil)
32+
33+func workflowOffKey(repo domain.RepoName, path string) []byte {
34+ return []byte(workflowOffPrefix + repo.String() + "/" + path)
35+}
36+
37+func workflowOffPrefixOf(repo domain.RepoName) string { return workflowOffPrefix + repo.String() + "/" }
38+
39+// DisableWorkflow records the path as disabled. One already disabled keeps its
40+// date. A repository that does not exist is domain.ErrNotFound.
41+func (c *CI) DisableWorkflow(ctx context.Context, repo domain.RepoName, path string, at time.Time) error {
42+ val, err := proto.Marshal(&storagepb.CIWorkflowDisabled{DisabledAt: timestamppb.New(at)})
43+ if err != nil {
44+ return fmt.Errorf("encode disabled workflow: %w", err)
45+ }
46+ key := workflowOffKey(repo, path)
47+ for range updateAttemptsTip {
48+ if _, ok := c.db.Get(ctx, key); ok {
49+ if _, ok := c.db.Get(ctx, repoKey(repo)); !ok {
50+ return domain.ErrNotFound
51+ }
52+ return nil
53+ }
54+ var b kv.Batch
55+ if err := c.guardRepo(ctx, &b, repo); err != nil {
56+ return err
57+ }
58+ b.RequireAbsent(key)
59+ b.Put(key, val)
60+ _, err := c.db.Apply(ctx, &b)
61+ if errors.Is(err, kv.ErrConflict) {
62+ continue
63+ }
64+ if err != nil {
65+ return fmt.Errorf("store disabled workflow: %w", err)
66+ }
67+ return nil
68+ }
69+ return fmt.Errorf("store disabled workflow: %w", domain.ErrConflict)
70+}
71+
72+// EnableWorkflow removes the record of the path.
73+func (c *CI) EnableWorkflow(ctx context.Context, repo domain.RepoName, path string) error {
74+ var b kv.Batch
75+ b.Delete(workflowOffKey(repo, path))
76+ if _, err := c.db.Apply(ctx, &b); err != nil {
77+ return fmt.Errorf("delete disabled workflow: %w", err)
78+ }
79+ return nil
80+}
81+
82+// DisabledWorkflows returns the disabled paths of the repository, sorted.
83+func (c *CI) DisabledWorkflows(ctx context.Context, repo domain.RepoName) ([]string, error) {
84+ prefix := workflowOffPrefixOf(repo)
85+ var out []string
86+ for k := range c.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
87+ out = append(out, strings.TrimPrefix(string(k), prefix))
88+ }
89+ sort.Strings(out)
90+ return out, nil
91+}
A internal/adapters/kvstore/ci_workflow_test.go +92 -0
@@ -0,0 +1,92 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+const wfRelease = ".github/workflows/release.yml"
17+
18+func TestCIWorkflowDisabledState(t *testing.T) {
19+ ctx := context.Background()
20+ s := openStore(t, t.TempDir())
21+ ci := s.CI()
22+ a, b := name(t, "a"), name(t, "b")
23+ for _, n := range []domain.RepoName{a, b} {
24+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created, Settings: domain.DefaultRepoSettings()}))
25+ }
26+
27+ got, err := ci.DisabledWorkflows(ctx, a)
28+ require.NoError(t, err)
29+ assert.Empty(t, got)
30+
31+ require.NoError(t, ci.DisableWorkflow(ctx, a, wfRelease, created))
32+ require.NoError(t, ci.DisableWorkflow(ctx, a, wfRelease, created), "idempotent")
33+ require.NoError(t, ci.DisableWorkflow(ctx, a, ".github/workflows/aur.yml", created))
34+ got, err = ci.DisabledWorkflows(ctx, a)
35+ require.NoError(t, err)
36+ assert.Equal(t, []string{".github/workflows/aur.yml", wfRelease}, got)
37+ got, _ = ci.DisabledWorkflows(ctx, b)
38+ assert.Empty(t, got, "per repository")
39+
40+ require.NoError(t, ci.EnableWorkflow(ctx, a, wfRelease))
41+ require.NoError(t, ci.EnableWorkflow(ctx, a, wfRelease), "idempotent")
42+ got, _ = ci.DisabledWorkflows(ctx, a)
43+ assert.Equal(t, []string{".github/workflows/aur.yml"}, got)
44+
45+ assert.ErrorIs(t, ci.DisableWorkflow(ctx, name(t, "gone"), wfRelease, created), domain.ErrNotFound)
46+
47+ // The state goes with the repository: a new one under the same name starts enabled.
48+ require.NoError(t, s.Delete(ctx, a))
49+ require.NoError(t, s.Create(ctx, domain.Repo{Name: a, Visibility: domain.VisibilityPrivate, CreatedAt: created, Settings: domain.DefaultRepoSettings()}))
50+ got, _ = ci.DisabledWorkflows(ctx, a)
51+ assert.Empty(t, got)
52+}
53+
54+// A run is refused for a disabled workflow in the very transaction that stores
55+// it: a disable that lands between the read of the state and the write of the
56+// run (here, from inside build) cannot be overtaken, and no run is left.
57+func TestCreateRunRefusesAWorkflowDisabledMeanwhile(t *testing.T) {
58+ ctx := context.Background()
59+ s := openStore(t, t.TempDir())
60+ ci := s.CI()
61+ n := name(t, "a")
62+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created, Settings: domain.DefaultRepoSettings()}))
63+ draft := func(id, number int64) ports.CIRunDraft {
64+ return ports.CIRunDraft{Run: domain.CIRun{Repo: n, ID: id, Number: number, Attempt: 1, WorkflowPath: wfRelease, Status: domain.CIQueued, CreatedAt: created}}
65+ }
66+
67+ // Enabled: a run is created.
68+ _, err := ci.CreateRun(ctx, n, wfRelease, draft)
69+ require.NoError(t, err)
70+
71+ // Disabled from inside build, after the state was read.
72+ calls := 0
73+ _, err = ci.CreateRun(ctx, n, wfRelease, func(id, number int64) ports.CIRunDraft {
74+ calls++
75+ if calls == 1 {
76+ require.NoError(t, ci.DisableWorkflow(ctx, n, wfRelease, created))
77+ }
78+ return draft(id, number)
79+ })
80+ assert.ErrorIs(t, err, domain.ErrWorkflowDisabled)
81+ assert.Equal(t, 1, calls, "refused on the retry, before build")
82+ runs, err := ci.ListRuns(ctx, n, 0, 10)
83+ require.NoError(t, err)
84+ assert.Len(t, runs, 1, "no run was stored by the refused call")
85+
86+ // Other workflows are not affected, and enabling restores it.
87+ _, err = ci.CreateRun(ctx, n, ".github/workflows/other.yml", draft)
88+ require.NoError(t, err)
89+ require.NoError(t, ci.EnableWorkflow(ctx, n, wfRelease))
90+ _, err = ci.CreateRun(ctx, n, wfRelease, draft)
91+ require.NoError(t, err)
92+}
@@ -0,0 +1,92 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+const wfRelease = ".github/workflows/release.yml"
17+
18+func TestCIWorkflowDisabledState(t *testing.T) {
19+ ctx := context.Background()
20+ s := openStore(t, t.TempDir())
21+ ci := s.CI()
22+ a, b := name(t, "a"), name(t, "b")
23+ for _, n := range []domain.RepoName{a, b} {
24+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created, Settings: domain.DefaultRepoSettings()}))
25+ }
26+
27+ got, err := ci.DisabledWorkflows(ctx, a)
28+ require.NoError(t, err)
29+ assert.Empty(t, got)
30+
31+ require.NoError(t, ci.DisableWorkflow(ctx, a, wfRelease, created))
32+ require.NoError(t, ci.DisableWorkflow(ctx, a, wfRelease, created), "idempotent")
33+ require.NoError(t, ci.DisableWorkflow(ctx, a, ".github/workflows/aur.yml", created))
34+ got, err = ci.DisabledWorkflows(ctx, a)
35+ require.NoError(t, err)
36+ assert.Equal(t, []string{".github/workflows/aur.yml", wfRelease}, got)
37+ got, _ = ci.DisabledWorkflows(ctx, b)
38+ assert.Empty(t, got, "per repository")
39+
40+ require.NoError(t, ci.EnableWorkflow(ctx, a, wfRelease))
41+ require.NoError(t, ci.EnableWorkflow(ctx, a, wfRelease), "idempotent")
42+ got, _ = ci.DisabledWorkflows(ctx, a)
43+ assert.Equal(t, []string{".github/workflows/aur.yml"}, got)
44+
45+ assert.ErrorIs(t, ci.DisableWorkflow(ctx, name(t, "gone"), wfRelease, created), domain.ErrNotFound)
46+
47+ // The state goes with the repository: a new one under the same name starts enabled.
48+ require.NoError(t, s.Delete(ctx, a))
49+ require.NoError(t, s.Create(ctx, domain.Repo{Name: a, Visibility: domain.VisibilityPrivate, CreatedAt: created, Settings: domain.DefaultRepoSettings()}))
50+ got, _ = ci.DisabledWorkflows(ctx, a)
51+ assert.Empty(t, got)
52+}
53+
54+// A run is refused for a disabled workflow in the very transaction that stores
55+// it: a disable that lands between the read of the state and the write of the
56+// run (here, from inside build) cannot be overtaken, and no run is left.
57+func TestCreateRunRefusesAWorkflowDisabledMeanwhile(t *testing.T) {
58+ ctx := context.Background()
59+ s := openStore(t, t.TempDir())
60+ ci := s.CI()
61+ n := name(t, "a")
62+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created, Settings: domain.DefaultRepoSettings()}))
63+ draft := func(id, number int64) ports.CIRunDraft {
64+ return ports.CIRunDraft{Run: domain.CIRun{Repo: n, ID: id, Number: number, Attempt: 1, WorkflowPath: wfRelease, Status: domain.CIQueued, CreatedAt: created}}
65+ }
66+
67+ // Enabled: a run is created.
68+ _, err := ci.CreateRun(ctx, n, wfRelease, draft)
69+ require.NoError(t, err)
70+
71+ // Disabled from inside build, after the state was read.
72+ calls := 0
73+ _, err = ci.CreateRun(ctx, n, wfRelease, func(id, number int64) ports.CIRunDraft {
74+ calls++
75+ if calls == 1 {
76+ require.NoError(t, ci.DisableWorkflow(ctx, n, wfRelease, created))
77+ }
78+ return draft(id, number)
79+ })
80+ assert.ErrorIs(t, err, domain.ErrWorkflowDisabled)
81+ assert.Equal(t, 1, calls, "refused on the retry, before build")
82+ runs, err := ci.ListRuns(ctx, n, 0, 10)
83+ require.NoError(t, err)
84+ assert.Len(t, runs, 1, "no run was stored by the refused call")
85+
86+ // Other workflows are not affected, and enabling restores it.
87+ _, err = ci.CreateRun(ctx, n, ".github/workflows/other.yml", draft)
88+ require.NoError(t, err)
89+ require.NoError(t, ci.EnableWorkflow(ctx, n, wfRelease))
90+ _, err = ci.CreateRun(ctx, n, wfRelease, draft)
91+ require.NoError(t, err)
92+}
M internal/adapters/rpc/api.go +1 -1
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
3535 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3636 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
3737 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache"}
38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
3939
4040 // FeaturePasskeys is listed by GetVersion when passkeys are available.
4141 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache"}38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
39 39
40 // FeaturePasskeys is listed by GetVersion when passkeys are available.40 // FeaturePasskeys is listed by GetVersion when passkeys are available.
41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
M internal/adapters/rpc/ci.go +48 -0
@@ -334,6 +334,54 @@ func (s *runService) DispatchWorkflow(ctx context.Context, req *connect.Request[
334334 return connect.NewResponse(&pb.DispatchWorkflowResponse{Run: runToPB(run)}), nil
335335 }
336336
337+func workflowToPB(w ports.WorkflowInfo) *pb.Workflow {
338+ state := pb.WorkflowState_WORKFLOW_STATE_ENABLED
339+ if w.Disabled {
340+ state = pb.WorkflowState_WORKFLOW_STATE_DISABLED
341+ }
342+ return &pb.Workflow{Path: w.Path, Name: w.Name, State: state}
343+}
344+
345+func (s *runService) ListWorkflows(ctx context.Context, req *connect.Request[pb.ListWorkflowsRequest]) (*connect.Response[pb.ListWorkflowsResponse], error) {
346+ name, err := repoOf(req.Msg.GetRepo())
347+ if err != nil {
348+ return nil, ciError(ctx, err)
349+ }
350+ list, err := s.runs.ListWorkflows(ctx, caller(ctx), name)
351+ if err != nil {
352+ return nil, ciError(ctx, err)
353+ }
354+ out := &pb.ListWorkflowsResponse{Workflows: make([]*pb.Workflow, 0, len(list))}
355+ for _, w := range list {
356+ out.Workflows = append(out.Workflows, workflowToPB(w))
357+ }
358+ return connect.NewResponse(out), nil
359+}
360+
361+func (s *runService) DisableWorkflow(ctx context.Context, req *connect.Request[pb.DisableWorkflowRequest]) (*connect.Response[pb.DisableWorkflowResponse], error) {
362+ name, err := repoOf(req.Msg.GetRepo())
363+ if err != nil {
364+ return nil, ciError(ctx, err)
365+ }
366+ w, err := s.runs.DisableWorkflow(ctx, caller(ctx), name, req.Msg.GetWorkflow())
367+ if err != nil {
368+ return nil, ciError(ctx, err)
369+ }
370+ return connect.NewResponse(&pb.DisableWorkflowResponse{Workflow: workflowToPB(w)}), nil
371+}
372+
373+func (s *runService) EnableWorkflow(ctx context.Context, req *connect.Request[pb.EnableWorkflowRequest]) (*connect.Response[pb.EnableWorkflowResponse], error) {
374+ name, err := repoOf(req.Msg.GetRepo())
375+ if err != nil {
376+ return nil, ciError(ctx, err)
377+ }
378+ w, err := s.runs.EnableWorkflow(ctx, caller(ctx), name, req.Msg.GetWorkflow())
379+ if err != nil {
380+ return nil, ciError(ctx, err)
381+ }
382+ return connect.NewResponse(&pb.EnableWorkflowResponse{Workflow: workflowToPB(w)}), nil
383+}
384+
337385 func (s *runService) ListChecks(ctx context.Context, req *connect.Request[pb.ListChecksRequest]) (*connect.Response[pb.ListChecksResponse], error) {
338386 name, err := repoOf(req.Msg.GetRepo())
339387 if err != nil {
@@ -334,6 +334,54 @@ func (s *runService) DispatchWorkflow(ctx context.Context, req *connect.Request[
334 return connect.NewResponse(&pb.DispatchWorkflowResponse{Run: runToPB(run)}), nil334 return connect.NewResponse(&pb.DispatchWorkflowResponse{Run: runToPB(run)}), nil
335 }335 }
336 336
337+func workflowToPB(w ports.WorkflowInfo) *pb.Workflow {
338+ state := pb.WorkflowState_WORKFLOW_STATE_ENABLED
339+ if w.Disabled {
340+ state = pb.WorkflowState_WORKFLOW_STATE_DISABLED
341+ }
342+ return &pb.Workflow{Path: w.Path, Name: w.Name, State: state}
343+}
344+
345+func (s *runService) ListWorkflows(ctx context.Context, req *connect.Request[pb.ListWorkflowsRequest]) (*connect.Response[pb.ListWorkflowsResponse], error) {
346+ name, err := repoOf(req.Msg.GetRepo())
347+ if err != nil {
348+ return nil, ciError(ctx, err)
349+ }
350+ list, err := s.runs.ListWorkflows(ctx, caller(ctx), name)
351+ if err != nil {
352+ return nil, ciError(ctx, err)
353+ }
354+ out := &pb.ListWorkflowsResponse{Workflows: make([]*pb.Workflow, 0, len(list))}
355+ for _, w := range list {
356+ out.Workflows = append(out.Workflows, workflowToPB(w))
357+ }
358+ return connect.NewResponse(out), nil
359+}
360+
361+func (s *runService) DisableWorkflow(ctx context.Context, req *connect.Request[pb.DisableWorkflowRequest]) (*connect.Response[pb.DisableWorkflowResponse], error) {
362+ name, err := repoOf(req.Msg.GetRepo())
363+ if err != nil {
364+ return nil, ciError(ctx, err)
365+ }
366+ w, err := s.runs.DisableWorkflow(ctx, caller(ctx), name, req.Msg.GetWorkflow())
367+ if err != nil {
368+ return nil, ciError(ctx, err)
369+ }
370+ return connect.NewResponse(&pb.DisableWorkflowResponse{Workflow: workflowToPB(w)}), nil
371+}
372+
373+func (s *runService) EnableWorkflow(ctx context.Context, req *connect.Request[pb.EnableWorkflowRequest]) (*connect.Response[pb.EnableWorkflowResponse], error) {
374+ name, err := repoOf(req.Msg.GetRepo())
375+ if err != nil {
376+ return nil, ciError(ctx, err)
377+ }
378+ w, err := s.runs.EnableWorkflow(ctx, caller(ctx), name, req.Msg.GetWorkflow())
379+ if err != nil {
380+ return nil, ciError(ctx, err)
381+ }
382+ return connect.NewResponse(&pb.EnableWorkflowResponse{Workflow: workflowToPB(w)}), nil
383+}
384+
337 func (s *runService) ListChecks(ctx context.Context, req *connect.Request[pb.ListChecksRequest]) (*connect.Response[pb.ListChecksResponse], error) {385 func (s *runService) ListChecks(ctx context.Context, req *connect.Request[pb.ListChecksRequest]) (*connect.Response[pb.ListChecksResponse], error) {
338 name, err := repoOf(req.Msg.GetRepo())386 name, err := repoOf(req.Msg.GetRepo())
339 if err != nil {387 if err != nil {
M internal/adapters/web/actions.templ +10 -0
@@ -12,6 +12,13 @@ templ CheckIcon(kind, state string) {
1212
1313 templ Actions(l Layout, p ActionsPage) {
1414 @Shell(l, nil) {
15+ if len(p.Disabled) > 0 {
16+ <p class="dim wf-off">disabled workflows (they start no run):
17+ for _, f := range p.Disabled {
18+ <span class="pill">{ f }</span>{ " " }
19+ }
20+ </p>
21+ }
1522 if len(p.Runs) == 0 {
1623 <p class="dim">no runs yet: a push that matches a workflow of .github/workflows starts one.</p>
1724 } else {
@@ -83,6 +90,9 @@ templ RecentActionsBlock(a RecentActions) {
8390 templ RunDetail(l Layout, p RunPage) {
8491 @Shell(l, runRail(p)) {
8592 @LiveSlot("run-notice")
93+ if p.WorkflowDisabled {
94+ <p class="dim wf-off"><span class="pill">disabled</span> this workflow is disabled: no event starts a run of it (<code>ermite workflow enable</code>).</p>
95+ }
8696 <div id="run-body">
8797 @RunBody(p)
8898 </div>
@@ -12,6 +12,13 @@ templ CheckIcon(kind, state string) {
12 12
13 templ Actions(l Layout, p ActionsPage) {13 templ Actions(l Layout, p ActionsPage) {
14 @Shell(l, nil) {14 @Shell(l, nil) {
15+ if len(p.Disabled) > 0 {
16+ <p class="dim wf-off">disabled workflows (they start no run):
17+ for _, f := range p.Disabled {
18+ <span class="pill">{ f }</span>{ " " }
19+ }
20+ </p>
21+ }
15 if len(p.Runs) == 0 {22 if len(p.Runs) == 0 {
16 <p class="dim">no runs yet: a push that matches a workflow of .github/workflows starts one.</p>23 <p class="dim">no runs yet: a push that matches a workflow of .github/workflows starts one.</p>
17 } else {24 } else {
@@ -83,6 +90,9 @@ templ RecentActionsBlock(a RecentActions) {
83 templ RunDetail(l Layout, p RunPage) {90 templ RunDetail(l Layout, p RunPage) {
84 @Shell(l, runRail(p)) {91 @Shell(l, runRail(p)) {
85 @LiveSlot("run-notice")92 @LiveSlot("run-notice")
93+ if p.WorkflowDisabled {
94+ <p class="dim wf-off"><span class="pill">disabled</span> this workflow is disabled: no event starts a run of it (<code>ermite workflow enable</code>).</p>
95+ }
86 <div id="run-body">96 <div id="run-body">
87 @RunBody(p)97 @RunBody(p)
88 </div>98 </div>
M internal/adapters/web/actions_artifacts_test.go +1 -0
@@ -108,6 +108,7 @@ func artifactsServer(t *testing.T, arts *fakeArtifacts) *passkeyFixture {
108108 Jobs: []ports.JobView{job}, LatestAttempt: 1,
109109 }, nil).Maybe()
110110 runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil).Maybe()
111+ runs.EXPECT().DisabledWorkflows(mock.Anything, mock.Anything, repo).Return(nil, nil).Maybe()
111112 runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, mock.Anything).Return(ports.SummaryList{}, nil).Maybe()
112113 browse := mocks.NewBrowseService(t)
113114 browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
@@ -108,6 +108,7 @@ func artifactsServer(t *testing.T, arts *fakeArtifacts) *passkeyFixture {
108 Jobs: []ports.JobView{job}, LatestAttempt: 1,108 Jobs: []ports.JobView{job}, LatestAttempt: 1,
109 }, nil).Maybe()109 }, nil).Maybe()
110 runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil).Maybe()110 runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil).Maybe()
111+ runs.EXPECT().DisabledWorkflows(mock.Anything, mock.Anything, repo).Return(nil, nil).Maybe()
111 runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, mock.Anything).Return(ports.SummaryList{}, nil).Maybe()112 runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, mock.Anything).Return(ports.SummaryList{}, nil).Maybe()
112 browse := mocks.NewBrowseService(t)113 browse := mocks.NewBrowseService(t)
113 browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()114 browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
M internal/adapters/web/actions_pages.go +19 -0
@@ -6,6 +6,7 @@ import (
66 "context"
77 "fmt"
88 "net/http"
9+ "slices"
910 "strconv"
1011 "strings"
1112 "time"
@@ -143,6 +144,9 @@ type ActionsPage struct {
143144 More string
144145 // NoRunner says that CI has no runner to run on yet.
145146 NoRunner bool
147+ // Disabled are the workflow files that start no run (`ermite workflow
148+ // disable`), as paths.
149+ Disabled []string
146150 }
147151
148152 // StepView is a step of a job.
@@ -193,6 +197,9 @@ type RunPage struct {
193197 Active bool
194198 // detail is what the page was built from.
195199 detail ports.RunDetail
200+ // WorkflowDisabled: the workflow file of the run is disabled now, so a new
201+ // event starts no run of it.
202+ WorkflowDisabled bool
196203 // Forms are the owner's actions; nil/empty when the visitor cannot.
197204 CancelAction, RerunAction, RerunFailedAction string
198205 CanRerunFailed bool
@@ -223,6 +230,7 @@ func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component,
223230 if page.NextPageToken != "" {
224231 p.More = actionsBase(d.name()) + "?page=" + page.NextPageToken
225232 }
233+ p.Disabled = s.disabledWorkflows(ctx, d)
226234 l := s.repoLayout(ctx, d, "actions")
227235 l.Title = "actions · " + d.name() + " · " + s.instance
228236 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=actions")
@@ -231,6 +239,16 @@ func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component,
231239 return Actions(l, p), http.StatusOK, nil
232240 }
233241
242+// disabledWorkflows are the disabled workflow paths of the repository; the
243+// pages show them as a note, so a failure to read them is not one of the page.
244+func (s *Server) disabledWorkflows(ctx context.Context, d *repoData) []string {
245+ off, err := s.cfg.Runs.DisabledWorkflows(ctx, principal(ctx), d.repo.Name)
246+ if err != nil {
247+ return nil
248+ }
249+ return off
250+}
251+
234252 func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow {
235253 row := RunRow{
236254 ID: strconv.FormatInt(run.ID, 10), Href: runHref(repo, run.ID),
@@ -318,6 +336,7 @@ func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int
318336 return nil, 0, err
319337 }
320338 p.Artifacts = s.runArtifacts(ctx, d.repo.Name, p)
339+ p.WorkflowDisabled = slices.Contains(s.disabledWorkflows(ctx, d), p.detail.Run.WorkflowPath)
321340 l := s.repoLayout(ctx, d, "actions")
322341 l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance
323342 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID)
@@ -6,6 +6,7 @@ import (
6 "context"6 "context"
7 "fmt"7 "fmt"
8 "net/http"8 "net/http"
9+ "slices"
9 "strconv"10 "strconv"
10 "strings"11 "strings"
11 "time"12 "time"
@@ -143,6 +144,9 @@ type ActionsPage struct {
143 More string144 More string
144 // NoRunner says that CI has no runner to run on yet.145 // NoRunner says that CI has no runner to run on yet.
145 NoRunner bool146 NoRunner bool
147+ // Disabled are the workflow files that start no run (`ermite workflow
148+ // disable`), as paths.
149+ Disabled []string
146 }150 }
147 151
148 // StepView is a step of a job.152 // StepView is a step of a job.
@@ -193,6 +197,9 @@ type RunPage struct {
193 Active bool197 Active bool
194 // detail is what the page was built from.198 // detail is what the page was built from.
195 detail ports.RunDetail199 detail ports.RunDetail
200+ // WorkflowDisabled: the workflow file of the run is disabled now, so a new
201+ // event starts no run of it.
202+ WorkflowDisabled bool
196 // Forms are the owner's actions; nil/empty when the visitor cannot.203 // Forms are the owner's actions; nil/empty when the visitor cannot.
197 CancelAction, RerunAction, RerunFailedAction string204 CancelAction, RerunAction, RerunFailedAction string
198 CanRerunFailed bool205 CanRerunFailed bool
@@ -223,6 +230,7 @@ func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component,
223 if page.NextPageToken != "" {230 if page.NextPageToken != "" {
224 p.More = actionsBase(d.name()) + "?page=" + page.NextPageToken231 p.More = actionsBase(d.name()) + "?page=" + page.NextPageToken
225 }232 }
233+ p.Disabled = s.disabledWorkflows(ctx, d)
226 l := s.repoLayout(ctx, d, "actions")234 l := s.repoLayout(ctx, d, "actions")
227 l.Title = "actions · " + d.name() + " · " + s.instance235 l.Title = "actions · " + d.name() + " · " + s.instance
228 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=actions")236 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=actions")
@@ -231,6 +239,16 @@ func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component,
231 return Actions(l, p), http.StatusOK, nil239 return Actions(l, p), http.StatusOK, nil
232 }240 }
233 241
242+// disabledWorkflows are the disabled workflow paths of the repository; the
243+// pages show them as a note, so a failure to read them is not one of the page.
244+func (s *Server) disabledWorkflows(ctx context.Context, d *repoData) []string {
245+ off, err := s.cfg.Runs.DisabledWorkflows(ctx, principal(ctx), d.repo.Name)
246+ if err != nil {
247+ return nil
248+ }
249+ return off
250+}
251+
234 func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow {252 func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow {
235 row := RunRow{253 row := RunRow{
236 ID: strconv.FormatInt(run.ID, 10), Href: runHref(repo, run.ID),254 ID: strconv.FormatInt(run.ID, 10), Href: runHref(repo, run.ID),
@@ -318,6 +336,7 @@ func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int
318 return nil, 0, err336 return nil, 0, err
319 }337 }
320 p.Artifacts = s.runArtifacts(ctx, d.repo.Name, p)338 p.Artifacts = s.runArtifacts(ctx, d.repo.Name, p)
339+ p.WorkflowDisabled = slices.Contains(s.disabledWorkflows(ctx, d), p.detail.Run.WorkflowPath)
321 l := s.repoLayout(ctx, d, "actions")340 l := s.repoLayout(ctx, d, "actions")
322 l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance341 l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance
323 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID)342 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID)
M internal/adapters/web/actions_results_test.go +33 -0
@@ -43,6 +43,7 @@ func TestSummariesAreRenderedSafely(t *testing.T) {
4343 }, "\n")
4444
4545 runs := mocks.NewRunService(t)
46+ runs.EXPECT().DisabledWorkflows(mock.Anything, mock.Anything, repo).Return(nil, nil).Maybe()
4647 runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{
4748 Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess},
4849 Jobs: []ports.JobView{job}, LatestAttempt: 1,
@@ -143,6 +144,7 @@ func runPageServer(t *testing.T, repo domain.RepoName, jobs []ports.JobView, run
143144 Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess},
144145 Jobs: jobs, LatestAttempt: 1,
145146 }, nil)
147+ runs.EXPECT().DisabledWorkflows(mock.Anything, mock.Anything, repo).Return(nil, nil).Maybe()
146148 runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil)
147149 browse := mocks.NewBrowseService(t)
148150 browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
@@ -235,3 +237,34 @@ func TestJobStateDoesNotReadTheRun(t *testing.T) {
235237 require.NoError(t, err)
236238 assert.Equal(t, gap, frags)
237239 }
240+
241+// A disabled workflow is said on the list of runs and on the page of its runs
242+// (`ermite workflow disable`).
243+func TestRunPagesSayWhenAWorkflowIsDisabled(t *testing.T) {
244+ t.Parallel()
245+ repo := repoName(t, "proj")
246+ const path = ".github/workflows/release.yml"
247+ runs := mocks.NewRunService(t)
248+ runs.EXPECT().DisabledWorkflows(mock.Anything, mock.Anything, repo).Return([]string{path}, nil)
249+ runs.EXPECT().ListRuns(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RunPage{}, nil)
250+ runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{
251+ Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "release", WorkflowPath: path, Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess},
252+ LatestAttempt: 1,
253+ }, nil)
254+ runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil)
255+ browse := mocks.NewBrowseService(t)
256+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
257+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Runs: runs})
258+ get := func(target string) string {
259+ rec := httptest.NewRecorder()
260+ srv.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, target, nil))
261+ require.Equal(t, http.StatusOK, rec.Code)
262+ return rec.Body.String()
263+ }
264+ list := get("/proj/actions")
265+ assert.Contains(t, list, "disabled workflows")
266+ assert.Contains(t, list, path)
267+ run := get("/proj/actions/runs/1")
268+ assert.Contains(t, run, "this workflow is disabled")
269+ assert.NotContains(t, run, "onclick", "no inline script")
270+}
@@ -43,6 +43,7 @@ func TestSummariesAreRenderedSafely(t *testing.T) {
43 }, "\n")43 }, "\n")
44 44
45 runs := mocks.NewRunService(t)45 runs := mocks.NewRunService(t)
46+ runs.EXPECT().DisabledWorkflows(mock.Anything, mock.Anything, repo).Return(nil, nil).Maybe()
46 runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{47 runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{
47 Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess},48 Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess},
48 Jobs: []ports.JobView{job}, LatestAttempt: 1,49 Jobs: []ports.JobView{job}, LatestAttempt: 1,
@@ -143,6 +144,7 @@ func runPageServer(t *testing.T, repo domain.RepoName, jobs []ports.JobView, run
143 Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess},144 Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess},
144 Jobs: jobs, LatestAttempt: 1,145 Jobs: jobs, LatestAttempt: 1,
145 }, nil)146 }, nil)
147+ runs.EXPECT().DisabledWorkflows(mock.Anything, mock.Anything, repo).Return(nil, nil).Maybe()
146 runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil)148 runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil)
147 browse := mocks.NewBrowseService(t)149 browse := mocks.NewBrowseService(t)
148 browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()150 browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
@@ -235,3 +237,34 @@ func TestJobStateDoesNotReadTheRun(t *testing.T) {
235 require.NoError(t, err)237 require.NoError(t, err)
236 assert.Equal(t, gap, frags)238 assert.Equal(t, gap, frags)
237 }239 }
240+
241+// A disabled workflow is said on the list of runs and on the page of its runs
242+// (`ermite workflow disable`).
243+func TestRunPagesSayWhenAWorkflowIsDisabled(t *testing.T) {
244+ t.Parallel()
245+ repo := repoName(t, "proj")
246+ const path = ".github/workflows/release.yml"
247+ runs := mocks.NewRunService(t)
248+ runs.EXPECT().DisabledWorkflows(mock.Anything, mock.Anything, repo).Return([]string{path}, nil)
249+ runs.EXPECT().ListRuns(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RunPage{}, nil)
250+ runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{
251+ Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "release", WorkflowPath: path, Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess},
252+ LatestAttempt: 1,
253+ }, nil)
254+ runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil)
255+ browse := mocks.NewBrowseService(t)
256+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
257+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Runs: runs})
258+ get := func(target string) string {
259+ rec := httptest.NewRecorder()
260+ srv.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, target, nil))
261+ require.Equal(t, http.StatusOK, rec.Code)
262+ return rec.Body.String()
263+ }
264+ list := get("/proj/actions")
265+ assert.Contains(t, list, "disabled workflows")
266+ assert.Contains(t, list, path)
267+ run := get("/proj/actions/runs/1")
268+ assert.Contains(t, run, "this workflow is disabled")
269+ assert.NotContains(t, run, "onclick", "no inline script")
270+}
M internal/adapters/web/actions_templ.go +932 -880 generated file: collapsed
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
A internal/app/ci_workflow_test.go +359 -0
@@ -0,0 +1,359 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/usecase"
16+)
17+
18+// `ermite workflow disable|enable|list` (P8, D5): a disabled workflow starts no
19+// run for any event, and enabling it restores it.
20+
21+const releaseWF = ".github/workflows/release.yml"
22+
23+// wfOn is a workflow file with the given "on:" block (indented under on:).
24+func wfOn(name, on string) string {
25+ return "name: " + name + "\non:\n" + on + "jobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
26+}
27+
28+func (c *ciHarness) setWorkflow(api *clients, disable bool, file string) (*pb.Workflow, error) {
29+ c.t.Helper()
30+ if disable {
31+ res, err := api.run.DisableWorkflow(c.ctx, connect.NewRequest(&pb.DisableWorkflowRequest{Repo: c.repo, Workflow: file}))
32+ if err != nil {
33+ return nil, err
34+ }
35+ return res.Msg.GetWorkflow(), nil
36+ }
37+ res, err := api.run.EnableWorkflow(c.ctx, connect.NewRequest(&pb.EnableWorkflowRequest{Repo: c.repo, Workflow: file}))
38+ if err != nil {
39+ return nil, err
40+ }
41+ return res.Msg.GetWorkflow(), nil
42+}
43+
44+func (c *ciHarness) listWorkflows(api *clients) map[string]*pb.Workflow {
45+ c.t.Helper()
46+ res, err := api.run.ListWorkflows(c.ctx, connect.NewRequest(&pb.ListWorkflowsRequest{Repo: c.repo}))
47+ require.NoError(c.t, err)
48+ out := map[string]*pb.Workflow{}
49+ for _, w := range res.Msg.GetWorkflows() {
50+ out[w.GetPath()] = w
51+ }
52+ return out
53+}
54+
55+func runsOfWorkflow(runs []*pb.Run, path string) []*pb.Run {
56+ var out []*pb.Run
57+ for _, r := range runs {
58+ if r.GetWorkflowPath() == path {
59+ out = append(out, r)
60+ }
61+ }
62+ return out
63+}
64+
65+// Disabled before the first push, on an empty repository where the file does
66+// not exist yet: the push of many refs starts nothing of it, the other
67+// workflows still run, and enabling restores it.
68+func TestWorkflowDisabledBeforeFirstPushStartsNoRun(t *testing.T) {
69+ c := newCIHarness(t, nil)
70+ runner := c.registerRunner("alpha", "ubuntu-latest")
71+
72+ w, err := c.setWorkflow(c.root, true, "release.yml")
73+ require.NoError(t, err, "the repository is empty and has no such file")
74+ assert.Equal(t, releaseWF, w.GetPath(), "stored by its normalized path")
75+ assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, w.GetState())
76+ _, err = c.setWorkflow(c.root, true, releaseWF)
77+ require.NoError(t, err, "idempotent, by path too")
78+
79+ // The list shows it though no branch holds the file.
80+ listed := c.listWorkflows(c.root)
81+ require.Len(t, listed, 1)
82+ assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, listed[releaseWF].GetState())
83+ assert.Empty(t, listed[releaseWF].GetName(), "no file, no name")
84+
85+ push := wfOn("release", " push:\n tags: ['v*']\n branches: [main]\n")
86+ c.push("main", map[string]string{
87+ releaseWF: push,
88+ ".github/workflows/z-ctrl.yml": wfOn("control", " push:\n branches: [main]\n"),
89+ }, "")
90+ c.waitFor("the control run", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/z-ctrl.yml")) == 1 })
91+ assert.Empty(t, runsOfWorkflow(c.runs(), releaseWF), "a disabled workflow starts no push run")
92+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ctrl")
93+
94+ // Tags: many at once.
95+ for _, tag := range []string{"v1", "v2", "v3"} {
96+ c.mustGit(c.wt, "tag", tag)
97+ }
98+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/"+c.repo+".git"), "v1", "v2", "v3")
99+ c.push("main", map[string]string{"x.txt": "x"}, "")
100+ c.waitFor("the control run of the next push", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/z-ctrl.yml")) == 2 })
101+ assert.Empty(t, runsOfWorkflow(c.runs(), releaseWF))
102+
103+ listed = c.listWorkflows(c.root)
104+ assert.Equal(t, "release", listed[releaseWF].GetName(), "the file now exists and has a name")
105+ assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, listed[releaseWF].GetState())
106+ assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_ENABLED, listed[".github/workflows/z-ctrl.yml"].GetState())
107+
108+ // Enabling restores it.
109+ w, err = c.setWorkflow(c.root, false, "release.yml")
110+ require.NoError(t, err)
111+ assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_ENABLED, w.GetState())
112+ _, err = c.setWorkflow(c.root, false, "release.yml")
113+ require.NoError(t, err, "idempotent")
114+ assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_ENABLED, c.listWorkflows(c.root)[releaseWF].GetState())
115+ c.push("main", map[string]string{"y.txt": "y"}, "")
116+ c.waitFor("the release run", func() bool { return len(runsOfWorkflow(c.runs(), releaseWF)) == 1 })
117+}
118+
119+// A disabled workflow does not fire on its schedule, and fires again once
120+// enabled, with no reload of the schedules.
121+func TestWorkflowDisabledStartsNoScheduledRun(t *testing.T) {
122+ c, clk, _ := newScheduleHarness(t, "2026-03-04 10:00:30")
123+ c.push("main", scheduleWorkflow("*/5 * * * *"), "")
124+ ci := c.forge.CI
125+ tick := func(at string) {
126+ clk.set(at)
127+ ci.ScheduleNow(c.ctx)
128+ }
129+ tick("2026-03-04 10:05:00")
130+ require.Len(t, c.scheduled(), 1, "enabled: fires")
131+ assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")))
132+
133+ _, err := c.setWorkflow(c.root, true, "nightly.yml")
134+ require.NoError(t, err)
135+ assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")), "the cache holds every workflow: the state is read when it fires")
136+ tick("2026-03-04 10:10:00")
137+ tick("2026-03-04 10:15:00")
138+ assert.Len(t, c.scheduled(), 1, "disabled: no run for the minutes 10:10 and 10:15")
139+
140+ // A push of the file while disabled changes nothing.
141+ c.push("main", scheduleWorkflow("*/5 * * * *", "0 0 1 1 *"), "again")
142+ tick("2026-03-04 10:20:00")
143+ assert.Len(t, c.scheduled(), 1)
144+
145+ _, err = c.setWorkflow(c.root, false, "nightly.yml")
146+ require.NoError(t, err)
147+ tick("2026-03-04 10:25:00")
148+ assert.Len(t, c.scheduled(), 2, "enabled again: fires, without catching up the missed minutes")
149+}
150+
151+// workflow_dispatch and pull_request are refused/skipped too, per file.
152+func TestWorkflowDisabledStartsNoDispatchOrPullRequestRun(t *testing.T) {
153+ c := newCIHarness(t, nil)
154+ runner := c.registerRunner("alpha", "ubuntu-latest")
155+ c.push("main", map[string]string{
156+ ".github/workflows/deploy.yml": dispatchWorkflow,
157+ ".github/workflows/pr.yml": wfOn("pr", " pull_request:\n"),
158+ ".github/workflows/other.yml": wfOn("other", " pull_request:\n"),
159+ }, "")
160+ c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
161+ c.push("feat", map[string]string{"f.txt": "f"}, "")
162+
163+ _, err := c.setWorkflow(c.root, true, "deploy.yml")
164+ require.NoError(t, err)
165+ _, err = c.setWorkflow(c.root, true, ".github/workflows/pr.yml")
166+ require.NoError(t, err)
167+
168+ _, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"})
169+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "a disabled workflow is not dispatched")
170+ assert.Contains(t, err.Error(), "disabled")
171+
172+ c.openMR(c.root, "feat")
173+ c.waitFor("the other workflow's run", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/other.yml")) == 1 })
174+ assert.Empty(t, runsOfWorkflow(c.runs(), ".github/workflows/pr.yml"), "no pull_request run")
175+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "other")
176+
177+ _, err = c.setWorkflow(c.root, false, "deploy.yml")
178+ require.NoError(t, err)
179+ run, err := c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"})
180+ require.NoError(t, err)
181+ assert.Equal(t, "workflow_dispatch", run.GetEvent())
182+}
183+
184+// A disabled workflow does not start a workflow_run workflow.
185+func TestWorkflowDisabledStartsNoWorkflowRunRun(t *testing.T) {
186+ c := newCIHarness(t, nil)
187+ runner := c.registerRunner("alpha", "ubuntu-latest")
188+ c.push("main", map[string]string{
189+ ".github/workflows/ci.yml": wfOn("ci", " push:\n"),
190+ ".github/workflows/after.yml": wfOn("after", " workflow_run:\n workflows: [ci]\n types: [completed]\n"),
191+ ".github/workflows/after2.yml": wfOn("after2", " workflow_run:\n workflows: [ci]\n types: [completed]\n"),
192+ }, "")
193+ _, err := c.setWorkflow(c.root, true, "after.yml")
194+ require.NoError(t, err)
195+ // Pushed before the disable took effect? The first push ran ci: finish it.
196+ job := runner.await()
197+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ci")
198+ c.waitFor("after2 runs", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/after2.yml")) == 1 })
199+ assert.Empty(t, runsOfWorkflow(c.runs(), ".github/workflows/after.yml"))
200+}
201+
202+// Scopes: repo:admin changes the state, repo:read lists, ci:write and
203+// repo:write do not change it; the shape is checked, not the existence.
204+func TestWorkflowStateAccessAndValidation(t *testing.T) {
205+ c := newCIHarness(t, nil)
206+ admin := c.api(c.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), "proj"))
207+ writer := c.api(c.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeCIWrite), "proj"))
208+ reader := c.api(c.token(scopes(domain.ScopeRepoRead), "proj"))
209+ stranger := c.api(c.token(scopes(domain.ScopeRepoAdmin), "other"))
210+ limited, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), []string{"agent/"}, "proj")
211+
212+ _, err := c.setWorkflow(admin, true, "release.yml")
213+ require.NoError(t, err)
214+ _, err = c.setWorkflow(writer, true, "aur.yml")
215+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "repo:write and ci:write are not enough")
216+ _, err = c.setWorkflow(writer, false, "release.yml")
217+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
218+ _, err = c.setWorkflow(reader, true, "aur.yml")
219+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
220+ _, err = c.setWorkflow(c.api(limited), true, "aur.yml")
221+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "a token limited to branches may not")
222+ _, err = c.setWorkflow(stranger, true, "aur.yml")
223+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err), "a repository the token cannot read")
224+
225+ for _, bad := range []string{"", "release", "release.txt", "sub/release.yml", ".github/workflows/sub/release.yml", "../release.yml", ".github/other/release.yml"} {
226+ _, err = c.setWorkflow(admin, true, bad)
227+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "%q", bad)
228+ }
229+
230+ assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, c.listWorkflows(reader)[releaseWF].GetState(), "repo:read lists")
231+ assert.Len(t, c.listWorkflows(reader), 1, "the refused changes left nothing")
232+ _, err = stranger.run.ListWorkflows(c.ctx, connect.NewRequest(&pb.ListWorkflowsRequest{Repo: c.repo}))
233+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
234+ _, err = c.setWorkflow(admin, true, ".github/workflows/Odd Name.YAML")
235+ require.NoError(t, err, "only the shape is checked")
236+
237+ _, err = admin.run.DisableWorkflow(c.ctx, connect.NewRequest(&pb.DisableWorkflowRequest{Repo: "nope", Workflow: "a.yml"}))
238+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
239+}
240+
241+// GO-001: the state is enforced where the run is stored. A dispatch that passed
242+// the check and is paused after resolving its ref (DispatchResolved) while the
243+// workflow is disabled creates no run.
244+func TestWorkflowDisabledBetweenDispatchCheckAndCreation(t *testing.T) {
245+ var c *ciHarness
246+ var disable func()
247+ c = newCIHarness(t, func(cfg *usecase.CIConfig) {
248+ cfg.DispatchResolved = func() {
249+ if disable != nil {
250+ disable()
251+ }
252+ }
253+ })
254+ c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "")
255+ before := len(c.runs())
256+
257+ disable = func() {
258+ disable = nil
259+ _, err := c.setWorkflow(c.root, true, "deploy.yml")
260+ require.NoError(t, err)
261+ }
262+ _, err := c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"})
263+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "the disable landed after the check")
264+ assert.Contains(t, err.Error(), "disabled")
265+ assert.Len(t, c.runs(), before, "no run was created")
266+
267+ _, err = c.setWorkflow(c.root, false, "deploy.yml")
268+ require.NoError(t, err)
269+ _, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"})
270+ require.NoError(t, err, "enabled again")
271+}
272+
273+// GO-001: an invalid workflow file gets no failed run either while it is
274+// disabled, on a push and on the failed-run path of a disable that races.
275+func TestWorkflowDisabledInvalidFileStartsNoFailedRun(t *testing.T) {
276+ c := newCIHarness(t, nil)
277+ c.registerRunner("alpha", "ubuntu-latest")
278+ _, err := c.setWorkflow(c.root, true, "broken.yml")
279+ require.NoError(t, err)
280+ c.push("main", map[string]string{
281+ ".github/workflows/broken.yml": "on: push\njobs: [this is not a workflow\n",
282+ ".github/workflows/bad2.yml": "on: push\njobs: [this is not a workflow\n",
283+ }, "")
284+ c.waitFor("the invalid enabled file fails", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/bad2.yml")) == 1 })
285+ assert.Empty(t, runsOfWorkflow(c.runs(), ".github/workflows/broken.yml"), "a disabled invalid file reports nothing")
286+}
287+
288+// GO-002: a schedule load that is overtaken by an enable (or a disable) neither
289+// loses the workflow nor fires a disabled one: the cache holds every workflow
290+// and the state is read when the schedule fires.
291+func TestScheduleLoadRacingWithEnableAndDisable(t *testing.T) {
292+ clk := &testClock{}
293+ clk.set("2026-03-04 10:00:30")
294+ var c *ciHarness
295+ var inLoad func()
296+ c = newCIHarness(t, func(cfg *usecase.CIConfig) {
297+ cfg.Clock = clk
298+ cfg.ScheduleTick = time.Hour
299+ cfg.ScheduleLoaded = func(domain.RepoName) {
300+ if f := inLoad; f != nil {
301+ inLoad = nil
302+ f()
303+ }
304+ }
305+ })
306+ c.registerRunner("alpha", "ubuntu-latest")
307+ ci := c.forge.CI
308+ tick := func(at string) {
309+ clk.set(at)
310+ ci.ScheduleNow(c.ctx)
311+ }
312+ // Disabled before the first push; the load of the schedules sees the file,
313+ // and an enable lands while it is being cached.
314+ _, err := c.setWorkflow(c.root, true, "nightly.yml")
315+ require.NoError(t, err)
316+ c.push("main", scheduleWorkflow("*/5 * * * *"), "")
317+ inLoad = func() {
318+ _, err := c.setWorkflow(c.root, false, "nightly.yml")
319+ require.NoError(t, err)
320+ }
321+ tick("2026-03-04 10:05:00")
322+ require.Len(t, c.scheduled(), 1, "enabled while the load was running: the workflow is there")
323+ assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")))
324+
325+ // And the other way: disabled while a load is running.
326+ require.NoError(t, func() error { _, err := c.setWorkflow(c.root, false, "nightly.yml"); return err }())
327+ c.push("main", scheduleWorkflow("*/5 * * * *", "0 0 1 1 *"), "second") // the tip moves: a new load
328+ inLoad = func() {
329+ _, err := c.setWorkflow(c.root, true, "nightly.yml")
330+ require.NoError(t, err)
331+ }
332+ tick("2026-03-04 10:10:00")
333+ assert.Len(t, c.scheduled(), 1, "disabled while the load was running: it does not fire")
334+ _, err = c.setWorkflow(c.root, false, "nightly.yml")
335+ require.NoError(t, err)
336+ tick("2026-03-04 10:15:00")
337+ assert.Len(t, c.scheduled(), 2, "enabled again: fires with the cached entry, nothing missing")
338+}
339+
340+// GO-003: one file has one key. Non-canonical spellings are refused by the API,
341+// for disable, enable and dispatch alike.
342+func TestWorkflowNonCanonicalPathsAreRefused(t *testing.T) {
343+ c := newCIHarness(t, nil)
344+ c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "")
345+ for _, bad := range []string{
346+ ".github/workflows//release.yml", "./.github/workflows/release.yml", ".github/workflows/sub/../release.yml",
347+ ".github/workflows/./release.yml",
348+ } {
349+ _, err := c.setWorkflow(c.root, true, bad)
350+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "disable %q", bad)
351+ _, err = c.setWorkflow(c.root, false, bad)
352+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "enable %q", bad)
353+ _, err = c.dispatch(c.root, bad, "", nil)
354+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "dispatch %q", bad)
355+ }
356+ assert.Empty(t, c.listWorkflows(c.root)[".github/workflows/release.yml"], "nothing was stored")
357+ _, err := c.dispatch(c.root, ".github/workflows/deploy.yml", "", map[string]string{"version": "1"})
358+ require.NoError(t, err, "the canonical path still dispatches")
359+}
@@ -0,0 +1,359 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/usecase"
16+)
17+
18+// `ermite workflow disable|enable|list` (P8, D5): a disabled workflow starts no
19+// run for any event, and enabling it restores it.
20+
21+const releaseWF = ".github/workflows/release.yml"
22+
23+// wfOn is a workflow file with the given "on:" block (indented under on:).
24+func wfOn(name, on string) string {
25+ return "name: " + name + "\non:\n" + on + "jobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
26+}
27+
28+func (c *ciHarness) setWorkflow(api *clients, disable bool, file string) (*pb.Workflow, error) {
29+ c.t.Helper()
30+ if disable {
31+ res, err := api.run.DisableWorkflow(c.ctx, connect.NewRequest(&pb.DisableWorkflowRequest{Repo: c.repo, Workflow: file}))
32+ if err != nil {
33+ return nil, err
34+ }
35+ return res.Msg.GetWorkflow(), nil
36+ }
37+ res, err := api.run.EnableWorkflow(c.ctx, connect.NewRequest(&pb.EnableWorkflowRequest{Repo: c.repo, Workflow: file}))
38+ if err != nil {
39+ return nil, err
40+ }
41+ return res.Msg.GetWorkflow(), nil
42+}
43+
44+func (c *ciHarness) listWorkflows(api *clients) map[string]*pb.Workflow {
45+ c.t.Helper()
46+ res, err := api.run.ListWorkflows(c.ctx, connect.NewRequest(&pb.ListWorkflowsRequest{Repo: c.repo}))
47+ require.NoError(c.t, err)
48+ out := map[string]*pb.Workflow{}
49+ for _, w := range res.Msg.GetWorkflows() {
50+ out[w.GetPath()] = w
51+ }
52+ return out
53+}
54+
55+func runsOfWorkflow(runs []*pb.Run, path string) []*pb.Run {
56+ var out []*pb.Run
57+ for _, r := range runs {
58+ if r.GetWorkflowPath() == path {
59+ out = append(out, r)
60+ }
61+ }
62+ return out
63+}
64+
65+// Disabled before the first push, on an empty repository where the file does
66+// not exist yet: the push of many refs starts nothing of it, the other
67+// workflows still run, and enabling restores it.
68+func TestWorkflowDisabledBeforeFirstPushStartsNoRun(t *testing.T) {
69+ c := newCIHarness(t, nil)
70+ runner := c.registerRunner("alpha", "ubuntu-latest")
71+
72+ w, err := c.setWorkflow(c.root, true, "release.yml")
73+ require.NoError(t, err, "the repository is empty and has no such file")
74+ assert.Equal(t, releaseWF, w.GetPath(), "stored by its normalized path")
75+ assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, w.GetState())
76+ _, err = c.setWorkflow(c.root, true, releaseWF)
77+ require.NoError(t, err, "idempotent, by path too")
78+
79+ // The list shows it though no branch holds the file.
80+ listed := c.listWorkflows(c.root)
81+ require.Len(t, listed, 1)
82+ assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, listed[releaseWF].GetState())
83+ assert.Empty(t, listed[releaseWF].GetName(), "no file, no name")
84+
85+ push := wfOn("release", " push:\n tags: ['v*']\n branches: [main]\n")
86+ c.push("main", map[string]string{
87+ releaseWF: push,
88+ ".github/workflows/z-ctrl.yml": wfOn("control", " push:\n branches: [main]\n"),
89+ }, "")
90+ c.waitFor("the control run", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/z-ctrl.yml")) == 1 })
91+ assert.Empty(t, runsOfWorkflow(c.runs(), releaseWF), "a disabled workflow starts no push run")
92+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ctrl")
93+
94+ // Tags: many at once.
95+ for _, tag := range []string{"v1", "v2", "v3"} {
96+ c.mustGit(c.wt, "tag", tag)
97+ }
98+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/"+c.repo+".git"), "v1", "v2", "v3")
99+ c.push("main", map[string]string{"x.txt": "x"}, "")
100+ c.waitFor("the control run of the next push", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/z-ctrl.yml")) == 2 })
101+ assert.Empty(t, runsOfWorkflow(c.runs(), releaseWF))
102+
103+ listed = c.listWorkflows(c.root)
104+ assert.Equal(t, "release", listed[releaseWF].GetName(), "the file now exists and has a name")
105+ assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, listed[releaseWF].GetState())
106+ assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_ENABLED, listed[".github/workflows/z-ctrl.yml"].GetState())
107+
108+ // Enabling restores it.
109+ w, err = c.setWorkflow(c.root, false, "release.yml")
110+ require.NoError(t, err)
111+ assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_ENABLED, w.GetState())
112+ _, err = c.setWorkflow(c.root, false, "release.yml")
113+ require.NoError(t, err, "idempotent")
114+ assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_ENABLED, c.listWorkflows(c.root)[releaseWF].GetState())
115+ c.push("main", map[string]string{"y.txt": "y"}, "")
116+ c.waitFor("the release run", func() bool { return len(runsOfWorkflow(c.runs(), releaseWF)) == 1 })
117+}
118+
119+// A disabled workflow does not fire on its schedule, and fires again once
120+// enabled, with no reload of the schedules.
121+func TestWorkflowDisabledStartsNoScheduledRun(t *testing.T) {
122+ c, clk, _ := newScheduleHarness(t, "2026-03-04 10:00:30")
123+ c.push("main", scheduleWorkflow("*/5 * * * *"), "")
124+ ci := c.forge.CI
125+ tick := func(at string) {
126+ clk.set(at)
127+ ci.ScheduleNow(c.ctx)
128+ }
129+ tick("2026-03-04 10:05:00")
130+ require.Len(t, c.scheduled(), 1, "enabled: fires")
131+ assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")))
132+
133+ _, err := c.setWorkflow(c.root, true, "nightly.yml")
134+ require.NoError(t, err)
135+ assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")), "the cache holds every workflow: the state is read when it fires")
136+ tick("2026-03-04 10:10:00")
137+ tick("2026-03-04 10:15:00")
138+ assert.Len(t, c.scheduled(), 1, "disabled: no run for the minutes 10:10 and 10:15")
139+
140+ // A push of the file while disabled changes nothing.
141+ c.push("main", scheduleWorkflow("*/5 * * * *", "0 0 1 1 *"), "again")
142+ tick("2026-03-04 10:20:00")
143+ assert.Len(t, c.scheduled(), 1)
144+
145+ _, err = c.setWorkflow(c.root, false, "nightly.yml")
146+ require.NoError(t, err)
147+ tick("2026-03-04 10:25:00")
148+ assert.Len(t, c.scheduled(), 2, "enabled again: fires, without catching up the missed minutes")
149+}
150+
151+// workflow_dispatch and pull_request are refused/skipped too, per file.
152+func TestWorkflowDisabledStartsNoDispatchOrPullRequestRun(t *testing.T) {
153+ c := newCIHarness(t, nil)
154+ runner := c.registerRunner("alpha", "ubuntu-latest")
155+ c.push("main", map[string]string{
156+ ".github/workflows/deploy.yml": dispatchWorkflow,
157+ ".github/workflows/pr.yml": wfOn("pr", " pull_request:\n"),
158+ ".github/workflows/other.yml": wfOn("other", " pull_request:\n"),
159+ }, "")
160+ c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
161+ c.push("feat", map[string]string{"f.txt": "f"}, "")
162+
163+ _, err := c.setWorkflow(c.root, true, "deploy.yml")
164+ require.NoError(t, err)
165+ _, err = c.setWorkflow(c.root, true, ".github/workflows/pr.yml")
166+ require.NoError(t, err)
167+
168+ _, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"})
169+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "a disabled workflow is not dispatched")
170+ assert.Contains(t, err.Error(), "disabled")
171+
172+ c.openMR(c.root, "feat")
173+ c.waitFor("the other workflow's run", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/other.yml")) == 1 })
174+ assert.Empty(t, runsOfWorkflow(c.runs(), ".github/workflows/pr.yml"), "no pull_request run")
175+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "other")
176+
177+ _, err = c.setWorkflow(c.root, false, "deploy.yml")
178+ require.NoError(t, err)
179+ run, err := c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"})
180+ require.NoError(t, err)
181+ assert.Equal(t, "workflow_dispatch", run.GetEvent())
182+}
183+
184+// A disabled workflow does not start a workflow_run workflow.
185+func TestWorkflowDisabledStartsNoWorkflowRunRun(t *testing.T) {
186+ c := newCIHarness(t, nil)
187+ runner := c.registerRunner("alpha", "ubuntu-latest")
188+ c.push("main", map[string]string{
189+ ".github/workflows/ci.yml": wfOn("ci", " push:\n"),
190+ ".github/workflows/after.yml": wfOn("after", " workflow_run:\n workflows: [ci]\n types: [completed]\n"),
191+ ".github/workflows/after2.yml": wfOn("after2", " workflow_run:\n workflows: [ci]\n types: [completed]\n"),
192+ }, "")
193+ _, err := c.setWorkflow(c.root, true, "after.yml")
194+ require.NoError(t, err)
195+ // Pushed before the disable took effect? The first push ran ci: finish it.
196+ job := runner.await()
197+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ci")
198+ c.waitFor("after2 runs", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/after2.yml")) == 1 })
199+ assert.Empty(t, runsOfWorkflow(c.runs(), ".github/workflows/after.yml"))
200+}
201+
202+// Scopes: repo:admin changes the state, repo:read lists, ci:write and
203+// repo:write do not change it; the shape is checked, not the existence.
204+func TestWorkflowStateAccessAndValidation(t *testing.T) {
205+ c := newCIHarness(t, nil)
206+ admin := c.api(c.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), "proj"))
207+ writer := c.api(c.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeCIWrite), "proj"))
208+ reader := c.api(c.token(scopes(domain.ScopeRepoRead), "proj"))
209+ stranger := c.api(c.token(scopes(domain.ScopeRepoAdmin), "other"))
210+ limited, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), []string{"agent/"}, "proj")
211+
212+ _, err := c.setWorkflow(admin, true, "release.yml")
213+ require.NoError(t, err)
214+ _, err = c.setWorkflow(writer, true, "aur.yml")
215+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "repo:write and ci:write are not enough")
216+ _, err = c.setWorkflow(writer, false, "release.yml")
217+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
218+ _, err = c.setWorkflow(reader, true, "aur.yml")
219+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
220+ _, err = c.setWorkflow(c.api(limited), true, "aur.yml")
221+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "a token limited to branches may not")
222+ _, err = c.setWorkflow(stranger, true, "aur.yml")
223+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err), "a repository the token cannot read")
224+
225+ for _, bad := range []string{"", "release", "release.txt", "sub/release.yml", ".github/workflows/sub/release.yml", "../release.yml", ".github/other/release.yml"} {
226+ _, err = c.setWorkflow(admin, true, bad)
227+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "%q", bad)
228+ }
229+
230+ assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, c.listWorkflows(reader)[releaseWF].GetState(), "repo:read lists")
231+ assert.Len(t, c.listWorkflows(reader), 1, "the refused changes left nothing")
232+ _, err = stranger.run.ListWorkflows(c.ctx, connect.NewRequest(&pb.ListWorkflowsRequest{Repo: c.repo}))
233+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
234+ _, err = c.setWorkflow(admin, true, ".github/workflows/Odd Name.YAML")
235+ require.NoError(t, err, "only the shape is checked")
236+
237+ _, err = admin.run.DisableWorkflow(c.ctx, connect.NewRequest(&pb.DisableWorkflowRequest{Repo: "nope", Workflow: "a.yml"}))
238+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
239+}
240+
241+// GO-001: the state is enforced where the run is stored. A dispatch that passed
242+// the check and is paused after resolving its ref (DispatchResolved) while the
243+// workflow is disabled creates no run.
244+func TestWorkflowDisabledBetweenDispatchCheckAndCreation(t *testing.T) {
245+ var c *ciHarness
246+ var disable func()
247+ c = newCIHarness(t, func(cfg *usecase.CIConfig) {
248+ cfg.DispatchResolved = func() {
249+ if disable != nil {
250+ disable()
251+ }
252+ }
253+ })
254+ c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "")
255+ before := len(c.runs())
256+
257+ disable = func() {
258+ disable = nil
259+ _, err := c.setWorkflow(c.root, true, "deploy.yml")
260+ require.NoError(t, err)
261+ }
262+ _, err := c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"})
263+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "the disable landed after the check")
264+ assert.Contains(t, err.Error(), "disabled")
265+ assert.Len(t, c.runs(), before, "no run was created")
266+
267+ _, err = c.setWorkflow(c.root, false, "deploy.yml")
268+ require.NoError(t, err)
269+ _, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"})
270+ require.NoError(t, err, "enabled again")
271+}
272+
273+// GO-001: an invalid workflow file gets no failed run either while it is
274+// disabled, on a push and on the failed-run path of a disable that races.
275+func TestWorkflowDisabledInvalidFileStartsNoFailedRun(t *testing.T) {
276+ c := newCIHarness(t, nil)
277+ c.registerRunner("alpha", "ubuntu-latest")
278+ _, err := c.setWorkflow(c.root, true, "broken.yml")
279+ require.NoError(t, err)
280+ c.push("main", map[string]string{
281+ ".github/workflows/broken.yml": "on: push\njobs: [this is not a workflow\n",
282+ ".github/workflows/bad2.yml": "on: push\njobs: [this is not a workflow\n",
283+ }, "")
284+ c.waitFor("the invalid enabled file fails", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/bad2.yml")) == 1 })
285+ assert.Empty(t, runsOfWorkflow(c.runs(), ".github/workflows/broken.yml"), "a disabled invalid file reports nothing")
286+}
287+
288+// GO-002: a schedule load that is overtaken by an enable (or a disable) neither
289+// loses the workflow nor fires a disabled one: the cache holds every workflow
290+// and the state is read when the schedule fires.
291+func TestScheduleLoadRacingWithEnableAndDisable(t *testing.T) {
292+ clk := &testClock{}
293+ clk.set("2026-03-04 10:00:30")
294+ var c *ciHarness
295+ var inLoad func()
296+ c = newCIHarness(t, func(cfg *usecase.CIConfig) {
297+ cfg.Clock = clk
298+ cfg.ScheduleTick = time.Hour
299+ cfg.ScheduleLoaded = func(domain.RepoName) {
300+ if f := inLoad; f != nil {
301+ inLoad = nil
302+ f()
303+ }
304+ }
305+ })
306+ c.registerRunner("alpha", "ubuntu-latest")
307+ ci := c.forge.CI
308+ tick := func(at string) {
309+ clk.set(at)
310+ ci.ScheduleNow(c.ctx)
311+ }
312+ // Disabled before the first push; the load of the schedules sees the file,
313+ // and an enable lands while it is being cached.
314+ _, err := c.setWorkflow(c.root, true, "nightly.yml")
315+ require.NoError(t, err)
316+ c.push("main", scheduleWorkflow("*/5 * * * *"), "")
317+ inLoad = func() {
318+ _, err := c.setWorkflow(c.root, false, "nightly.yml")
319+ require.NoError(t, err)
320+ }
321+ tick("2026-03-04 10:05:00")
322+ require.Len(t, c.scheduled(), 1, "enabled while the load was running: the workflow is there")
323+ assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")))
324+
325+ // And the other way: disabled while a load is running.
326+ require.NoError(t, func() error { _, err := c.setWorkflow(c.root, false, "nightly.yml"); return err }())
327+ c.push("main", scheduleWorkflow("*/5 * * * *", "0 0 1 1 *"), "second") // the tip moves: a new load
328+ inLoad = func() {
329+ _, err := c.setWorkflow(c.root, true, "nightly.yml")
330+ require.NoError(t, err)
331+ }
332+ tick("2026-03-04 10:10:00")
333+ assert.Len(t, c.scheduled(), 1, "disabled while the load was running: it does not fire")
334+ _, err = c.setWorkflow(c.root, false, "nightly.yml")
335+ require.NoError(t, err)
336+ tick("2026-03-04 10:15:00")
337+ assert.Len(t, c.scheduled(), 2, "enabled again: fires with the cached entry, nothing missing")
338+}
339+
340+// GO-003: one file has one key. Non-canonical spellings are refused by the API,
341+// for disable, enable and dispatch alike.
342+func TestWorkflowNonCanonicalPathsAreRefused(t *testing.T) {
343+ c := newCIHarness(t, nil)
344+ c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "")
345+ for _, bad := range []string{
346+ ".github/workflows//release.yml", "./.github/workflows/release.yml", ".github/workflows/sub/../release.yml",
347+ ".github/workflows/./release.yml",
348+ } {
349+ _, err := c.setWorkflow(c.root, true, bad)
350+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "disable %q", bad)
351+ _, err = c.setWorkflow(c.root, false, bad)
352+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "enable %q", bad)
353+ _, err = c.dispatch(c.root, bad, "", nil)
354+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "dispatch %q", bad)
355+ }
356+ assert.Empty(t, c.listWorkflows(c.root)[".github/workflows/release.yml"], "nothing was stored")
357+ _, err := c.dispatch(c.root, ".github/workflows/deploy.yml", "", map[string]string{"version": "1"})
358+ require.NoError(t, err, "the canonical path still dispatches")
359+}
M internal/app/cli_e2e_test.go +1 -1
@@ -338,7 +338,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
338338 ver := decode[versionOut](t, res.stdout)
339339 assert.Equal(t, c.url, ver.Server.URL)
340340 assert.NotEmpty(t, ver.Server.Version)
341- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
341+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
342342 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
343343 stdout, stderr, code = c.bin(t, secret, "version")
344344 require.Equal(t, 0, code, stderr)
@@ -338,7 +338,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
338 ver := decode[versionOut](t, res.stdout)338 ver := decode[versionOut](t, res.stdout)
339 assert.Equal(t, c.url, ver.Server.URL)339 assert.Equal(t, c.url, ver.Server.URL)
340 assert.NotEmpty(t, ver.Server.Version)340 assert.NotEmpty(t, ver.Server.Version)
341- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)341+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
342 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)342 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
343 stdout, stderr, code = c.bin(t, secret, "version")343 stdout, stderr, code = c.bin(t, secret, "version")
344 require.Equal(t, 0, code, stderr)344 require.Equal(t, 0, code, stderr)
M internal/app/cli_run_test.go +51 -0
@@ -136,3 +136,54 @@ func TestRefTextShowsMergeRequests(t *testing.T) {
136136 assert.Equal(t, "main", refText("refs/heads/main"))
137137 assert.Equal(t, "v1", refText("refs/tags/v1"))
138138 }
139+
140+// `ermite workflow disable|enable|list`, over a real server: right after
141+// `repo create`, before any push, then once the file exists.
142+func TestCLIWorkflowDisableEnableList(t *testing.T) {
143+ ci := newCIHarness(t, nil)
144+ h := &cliHarness{harness: ci.harness, url: ci.srv.URL}
145+
146+ dis := h.cli(t, adminToken, "workflow", "disable", "proj", "release.yml")
147+ require.NoError(t, dis.err, dis.stderr)
148+ assert.Contains(t, dis.stderr, "disabled .github/workflows/release.yml in proj")
149+
150+ empty := h.cli(t, adminToken, "workflow", "list", "proj")
151+ require.NoError(t, empty.err, empty.stderr)
152+ assert.Contains(t, empty.stdout, "FILE")
153+ assert.Contains(t, empty.stdout, ".github/workflows/release.yml")
154+ assert.Contains(t, empty.stdout, "disabled")
155+
156+ ci.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow, releaseWF: wfOn("release", " push:\n")}, "")
157+ js := h.cli(t, adminToken, "workflow", "list", "proj", "--json")
158+ require.NoError(t, js.err, js.stderr)
159+ list := decode[[]jsonWorkflow](t, js.stdout)
160+ assert.Equal(t, []jsonWorkflow{
161+ {Repo: "proj", File: ".github/workflows/ci.yml", Name: "ci", State: "enabled"},
162+ {Repo: "proj", File: releaseWF, Name: "release", State: "disabled"},
163+ }, list)
164+ raw := decode[[]map[string]any](t, js.stdout)
165+ assert.Len(t, raw[0], 4, "repo, file, name, state")
166+ for _, k := range []string{"repo", "file", "name", "state"} {
167+ assert.Contains(t, raw[0], k)
168+ }
169+
170+ en := h.cli(t, adminToken, "workflow", "enable", "proj", "release.yml", "--json")
171+ require.NoError(t, en.err, en.stderr)
172+ assert.Equal(t, jsonWorkflow{Repo: "proj", File: releaseWF, State: "enabled"}, decode[jsonWorkflow](t, en.stdout))
173+ after := decode[[]jsonWorkflow](t, h.cli(t, adminToken, "workflow", "list", "proj", "--json").stdout)
174+ assert.Equal(t, "enabled", after[1].State)
175+
176+ bad := h.cli(t, adminToken, "workflow", "disable", "proj", "sub/x.yml")
177+ require.Error(t, bad.err)
178+ missing := h.cli(t, adminToken, "workflow", "disable", "nope", "x.yml")
179+ require.Error(t, missing.err)
180+
181+ reader := h.cli(t, ci.token(scopes(domain.ScopeRepoRead), "proj"), "workflow", "disable", "proj", "release.yml")
182+ require.Error(t, reader.err)
183+ assert.Contains(t, reader.err.Error(), "permission denied")
184+
185+ help := h.cli(t, adminToken, "workflow", "disable", "--help")
186+ require.NoError(t, help.err)
187+ assert.Contains(t, help.stdout, "JSON fields of a workflow (--json): repo, file")
188+ assert.Contains(t, help.stdout, "repo:admin")
189+}
@@ -136,3 +136,54 @@ func TestRefTextShowsMergeRequests(t *testing.T) {
136 assert.Equal(t, "main", refText("refs/heads/main"))136 assert.Equal(t, "main", refText("refs/heads/main"))
137 assert.Equal(t, "v1", refText("refs/tags/v1"))137 assert.Equal(t, "v1", refText("refs/tags/v1"))
138 }138 }
139+
140+// `ermite workflow disable|enable|list`, over a real server: right after
141+// `repo create`, before any push, then once the file exists.
142+func TestCLIWorkflowDisableEnableList(t *testing.T) {
143+ ci := newCIHarness(t, nil)
144+ h := &cliHarness{harness: ci.harness, url: ci.srv.URL}
145+
146+ dis := h.cli(t, adminToken, "workflow", "disable", "proj", "release.yml")
147+ require.NoError(t, dis.err, dis.stderr)
148+ assert.Contains(t, dis.stderr, "disabled .github/workflows/release.yml in proj")
149+
150+ empty := h.cli(t, adminToken, "workflow", "list", "proj")
151+ require.NoError(t, empty.err, empty.stderr)
152+ assert.Contains(t, empty.stdout, "FILE")
153+ assert.Contains(t, empty.stdout, ".github/workflows/release.yml")
154+ assert.Contains(t, empty.stdout, "disabled")
155+
156+ ci.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow, releaseWF: wfOn("release", " push:\n")}, "")
157+ js := h.cli(t, adminToken, "workflow", "list", "proj", "--json")
158+ require.NoError(t, js.err, js.stderr)
159+ list := decode[[]jsonWorkflow](t, js.stdout)
160+ assert.Equal(t, []jsonWorkflow{
161+ {Repo: "proj", File: ".github/workflows/ci.yml", Name: "ci", State: "enabled"},
162+ {Repo: "proj", File: releaseWF, Name: "release", State: "disabled"},
163+ }, list)
164+ raw := decode[[]map[string]any](t, js.stdout)
165+ assert.Len(t, raw[0], 4, "repo, file, name, state")
166+ for _, k := range []string{"repo", "file", "name", "state"} {
167+ assert.Contains(t, raw[0], k)
168+ }
169+
170+ en := h.cli(t, adminToken, "workflow", "enable", "proj", "release.yml", "--json")
171+ require.NoError(t, en.err, en.stderr)
172+ assert.Equal(t, jsonWorkflow{Repo: "proj", File: releaseWF, State: "enabled"}, decode[jsonWorkflow](t, en.stdout))
173+ after := decode[[]jsonWorkflow](t, h.cli(t, adminToken, "workflow", "list", "proj", "--json").stdout)
174+ assert.Equal(t, "enabled", after[1].State)
175+
176+ bad := h.cli(t, adminToken, "workflow", "disable", "proj", "sub/x.yml")
177+ require.Error(t, bad.err)
178+ missing := h.cli(t, adminToken, "workflow", "disable", "nope", "x.yml")
179+ require.Error(t, missing.err)
180+
181+ reader := h.cli(t, ci.token(scopes(domain.ScopeRepoRead), "proj"), "workflow", "disable", "proj", "release.yml")
182+ require.Error(t, reader.err)
183+ assert.Contains(t, reader.err.Error(), "permission denied")
184+
185+ help := h.cli(t, adminToken, "workflow", "disable", "--help")
186+ require.NoError(t, help.err)
187+ assert.Contains(t, help.stdout, "JSON fields of a workflow (--json): repo, file")
188+ assert.Contains(t, help.stdout, "repo:admin")
189+}
M internal/app/cli_version.go +1 -0
@@ -120,6 +120,7 @@ var featureUse = map[string]string{
120120 client.FeatureCIRunners: "`ermite runner` needs: the commands will fail on this server",
121121 client.FeatureCISecrets: "`ermite secret`, `variable` and `environment` need: the commands will fail on this server",
122122 client.FeatureCIDispatch: "`ermite workflow run` needs: the command will fail on this server",
123+ client.FeatureCIWorkflows: "`ermite workflow list`, `disable` and `enable` need: the commands will fail on this server",
123124 client.FeatureCIArtifacts: "`ermite run artifacts` and `run download` need: the commands will fail on this server",
124125 client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)",
125126 client.FeatureCommitSignatures: "`ermite signing-key trust` needs: the commands will fail on this server",
@@ -120,6 +120,7 @@ var featureUse = map[string]string{
120 client.FeatureCIRunners: "`ermite runner` needs: the commands will fail on this server",120 client.FeatureCIRunners: "`ermite runner` needs: the commands will fail on this server",
121 client.FeatureCISecrets: "`ermite secret`, `variable` and `environment` need: the commands will fail on this server",121 client.FeatureCISecrets: "`ermite secret`, `variable` and `environment` need: the commands will fail on this server",
122 client.FeatureCIDispatch: "`ermite workflow run` needs: the command will fail on this server",122 client.FeatureCIDispatch: "`ermite workflow run` needs: the command will fail on this server",
123+ client.FeatureCIWorkflows: "`ermite workflow list`, `disable` and `enable` need: the commands will fail on this server",
123 client.FeatureCIArtifacts: "`ermite run artifacts` and `run download` need: the commands will fail on this server",124 client.FeatureCIArtifacts: "`ermite run artifacts` and `run download` need: the commands will fail on this server",
124 client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)",125 client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)",
125 client.FeatureCommitSignatures: "`ermite signing-key trust` needs: the commands will fail on this server",126 client.FeatureCommitSignatures: "`ermite signing-key trust` needs: the commands will fail on this server",
M internal/app/cli_version_test.go +5 -5
@@ -17,7 +17,7 @@ import (
1717 )
1818
1919 // allFeatures are the features this client uses.
20-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.artifacts", "commit_signatures", "passkeys"}
20+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"}
2121
2222 // Every feature the client uses needs a sentence for the version warning.
2323 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
4545 f := versionFake(allFeatures...)
4646 url := f.serve(t)
4747 res := runCLI(t, envFor(url), "", "version").ok(t)
48- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.artifacts, commit_signatures, passkeys\n", res.stdout)
48+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout)
4949 assert.Empty(t, res.stderr, "no warning when every feature is there")
5050
5151 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
52- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout)
52+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout)
5353 }
5454
5555 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
56- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.artifacts", "commit_signatures", "passkeys")
56+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys")
5757 url := f.serve(t)
5858 res := runCLI(t, envFor(url), "", "version").ok(t)
5959 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
6161 assert.NotContains(t, res.stderr, "repo.update")
6262
6363 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
64- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
64+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
6565 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
6666
6767 f = versionFake()
@@ -17,7 +17,7 @@ import (
17 )17 )
18 18
19 // allFeatures are the features this client uses.19 // allFeatures are the features this client uses.
20-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.artifacts", "commit_signatures", "passkeys"}20+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"}
21 21
22 // Every feature the client uses needs a sentence for the version warning.22 // Every feature the client uses needs a sentence for the version warning.
23 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {23 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
45 f := versionFake(allFeatures...)45 f := versionFake(allFeatures...)
46 url := f.serve(t)46 url := f.serve(t)
47 res := runCLI(t, envFor(url), "", "version").ok(t)47 res := runCLI(t, envFor(url), "", "version").ok(t)
48- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.artifacts, commit_signatures, passkeys\n", res.stdout)48+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout)
49 assert.Empty(t, res.stderr, "no warning when every feature is there")49 assert.Empty(t, res.stderr, "no warning when every feature is there")
50 50
51 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)51 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
52- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout)52+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout)
53 }53 }
54 54
55 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {55 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
56- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.artifacts", "commit_signatures", "passkeys")56+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys")
57 url := f.serve(t)57 url := f.serve(t)
58 res := runCLI(t, envFor(url), "", "version").ok(t)58 res := runCLI(t, envFor(url), "", "version").ok(t)
59 assert.Contains(t, res.stdout, "v0.4.0")59 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
61 assert.NotContains(t, res.stderr, "repo.update")61 assert.NotContains(t, res.stderr, "repo.update")
62 62
63 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)63 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
64- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)64+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
65 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")65 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
66 66
67 f = versionFake()67 f = versionFake()
M internal/app/cli_workflow.go +132 -3
@@ -13,14 +13,15 @@ import (
1313 )
1414
1515 // `ermite workflow run`: start a workflow that has a workflow_dispatch trigger,
16-// like `gh workflow run`.
16+// like `gh workflow run`. `workflow list|disable|enable` show and set the state
17+// of the workflow files of a repository, like `gh workflow list|disable|enable`.
1718
1819 func newWorkflowCmd(env *cliEnv) *cobra.Command {
1920 wf := &cobra.Command{
2021 Use: "workflow",
21- Short: "Start workflows by hand",
22+ Short: "Start, list, disable and enable workflows",
2223 }
23- wf.AddCommand(newWorkflowRunCmd(env))
24+ wf.AddCommand(newWorkflowRunCmd(env), newWorkflowListCmd(env), newWorkflowDisableCmd(env), newWorkflowEnableCmd(env))
2425 return wf
2526 }
2627
@@ -83,3 +84,131 @@ func newWorkflowRunCmd(env *cliEnv) *cobra.Command {
8384 }
8485 return cmd
8586 }
87+
88+type jsonWorkflow struct {
89+ Repo string `json:"repo"`
90+ File string `json:"file"`
91+ Name string `json:"name"`
92+ State string `json:"state"`
93+}
94+
95+const jsonWorkflowHelp = `
96+JSON fields of a workflow (--json): repo, file (the path, always .github/workflows/<file>), name (the
97+"name:" of the workflow on the default branch, the path when it has none, empty when the default branch
98+has no such file) and state (enabled or disabled).`
99+
100+func workflowStateText(s ermite.WorkflowState) string {
101+ if s == ermite.WorkflowState_WORKFLOW_STATE_DISABLED {
102+ return "disabled"
103+ }
104+ return "enabled"
105+}
106+
107+func toJSONWorkflow(repo string, w *ermite.Workflow) jsonWorkflow {
108+ return jsonWorkflow{Repo: repo, File: w.GetPath(), Name: w.GetName(), State: workflowStateText(w.GetState())}
109+}
110+
111+func newWorkflowListCmd(env *cliEnv) *cobra.Command {
112+ cmd := &cobra.Command{
113+ Use: "list <repo>",
114+ Short: "List the workflows of a repository and whether they are enabled",
115+ Long: "List the workflow files of .github/workflows on the default branch with their state, and the disabled\n" +
116+ "workflows whose file is absent there (a disabled workflow is listed even in an empty repository), ordered\n" +
117+ "by file. Needs the repo:read scope for a private repository." + jsonWorkflowHelp + "\n--json prints an array of workflows.",
118+ Example: " ermite workflow list app\n ermite workflow list app --json",
119+ Args: cobra.ExactArgs(1),
120+ ValidArgsFunction: completeRepoNames(env),
121+ }
122+ asJSON := addJSONFlag(cmd)
123+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
124+ c, err := env.client(cmd)
125+ if err != nil {
126+ return err
127+ }
128+ res, err := c.Runs().ListWorkflows(env.ctx(cmd), connect.NewRequest(&ermite.ListWorkflowsRequest{Repo: args[0]}))
129+ if err != nil {
130+ return c.Describe(err)
131+ }
132+ list := res.Msg.GetWorkflows()
133+ if *asJSON {
134+ out := make([]jsonWorkflow, 0, len(list))
135+ for _, w := range list {
136+ out = append(out, toJSONWorkflow(args[0], w))
137+ }
138+ return writeJSON(cmd.OutOrStdout(), out)
139+ }
140+ if len(list) == 0 {
141+ noticef(cmd, "no workflows")
142+ return nil
143+ }
144+ rows := make([][]string, 0, len(list))
145+ for _, w := range list {
146+ rows = append(rows, []string{w.GetPath(), w.GetName(), workflowStateText(w.GetState())})
147+ }
148+ return table(cmd.OutOrStdout(), []string{"FILE", "NAME", "STATE"}, rows)
149+ }
150+ return cmd
151+}
152+
153+func newWorkflowDisableCmd(env *cliEnv) *cobra.Command {
154+ cmd := &cobra.Command{
155+ Use: "disable <repo> <file>",
156+ Short: "Stop a workflow from starting runs",
157+ Long: "Disable a workflow file of a repository, like `gh workflow disable`: no event (push, pull_request,\n" +
158+ "pull_request_target, schedule, workflow_run, create, delete, workflow_dispatch) starts a run of it any more.\n" +
159+ "Runs already started go on. The state belongs to the repository and to the file path, not to a branch. The\n" +
160+ "file is its path or its file name (release.yml) and does not have to exist: disable it right after\n" +
161+ "`ermite repo create`, before the first push. Disabling a disabled workflow changes nothing. Undo with\n" +
162+ "`ermite workflow enable`. Needs the repo:admin scope." + jsonWorkflowHelp,
163+ Example: " ermite workflow disable app release.yml\n ermite workflow disable app .github/workflows/codeql.yml",
164+ Args: cobra.ExactArgs(2),
165+ ValidArgsFunction: completeRepoNames(env),
166+ }
167+ asJSON := addJSONFlag(cmd)
168+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
169+ c, err := env.client(cmd)
170+ if err != nil {
171+ return err
172+ }
173+ res, err := c.Runs().DisableWorkflow(env.ctx(cmd), connect.NewRequest(&ermite.DisableWorkflowRequest{Repo: args[0], Workflow: args[1]}))
174+ if err != nil {
175+ return c.Describe(err)
176+ }
177+ return printWorkflowState(cmd, *asJSON, args[0], res.Msg.GetWorkflow(), "disabled")
178+ }
179+ return cmd
180+}
181+
182+func newWorkflowEnableCmd(env *cliEnv) *cobra.Command {
183+ cmd := &cobra.Command{
184+ Use: "enable <repo> <file>",
185+ Short: "Let a disabled workflow start runs again",
186+ Long: "Enable a workflow file of a repository, like `gh workflow enable`. The events that happened while it was\n" +
187+ "disabled are not replayed. Enabling a workflow that is not disabled changes nothing. The file is its path\n" +
188+ "or its file name. Needs the repo:admin scope." + jsonWorkflowHelp,
189+ Example: " ermite workflow enable app release.yml",
190+ Args: cobra.ExactArgs(2),
191+ ValidArgsFunction: completeRepoNames(env),
192+ }
193+ asJSON := addJSONFlag(cmd)
194+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
195+ c, err := env.client(cmd)
196+ if err != nil {
197+ return err
198+ }
199+ res, err := c.Runs().EnableWorkflow(env.ctx(cmd), connect.NewRequest(&ermite.EnableWorkflowRequest{Repo: args[0], Workflow: args[1]}))
200+ if err != nil {
201+ return c.Describe(err)
202+ }
203+ return printWorkflowState(cmd, *asJSON, args[0], res.Msg.GetWorkflow(), "enabled")
204+ }
205+ return cmd
206+}
207+
208+func printWorkflowState(cmd *cobra.Command, asJSON bool, repo string, w *ermite.Workflow, state string) error {
209+ if asJSON {
210+ return writeJSON(cmd.OutOrStdout(), toJSONWorkflow(repo, w))
211+ }
212+ noticef(cmd, "%s %s in %s", state, clean(w.GetPath()), clean(repo))
213+ return nil
214+}
@@ -13,14 +13,15 @@ import (
13 )13 )
14 14
15 // `ermite workflow run`: start a workflow that has a workflow_dispatch trigger,15 // `ermite workflow run`: start a workflow that has a workflow_dispatch trigger,
16-// like `gh workflow run`.16+// like `gh workflow run`. `workflow list|disable|enable` show and set the state
17+// of the workflow files of a repository, like `gh workflow list|disable|enable`.
17 18
18 func newWorkflowCmd(env *cliEnv) *cobra.Command {19 func newWorkflowCmd(env *cliEnv) *cobra.Command {
19 wf := &cobra.Command{20 wf := &cobra.Command{
20 Use: "workflow",21 Use: "workflow",
21- Short: "Start workflows by hand",22+ Short: "Start, list, disable and enable workflows",
22 }23 }
23- wf.AddCommand(newWorkflowRunCmd(env))24+ wf.AddCommand(newWorkflowRunCmd(env), newWorkflowListCmd(env), newWorkflowDisableCmd(env), newWorkflowEnableCmd(env))
24 return wf25 return wf
25 }26 }
26 27
@@ -83,3 +84,131 @@ func newWorkflowRunCmd(env *cliEnv) *cobra.Command {
83 }84 }
84 return cmd85 return cmd
85 }86 }
87+
88+type jsonWorkflow struct {
89+ Repo string `json:"repo"`
90+ File string `json:"file"`
91+ Name string `json:"name"`
92+ State string `json:"state"`
93+}
94+
95+const jsonWorkflowHelp = `
96+JSON fields of a workflow (--json): repo, file (the path, always .github/workflows/<file>), name (the
97+"name:" of the workflow on the default branch, the path when it has none, empty when the default branch
98+has no such file) and state (enabled or disabled).`
99+
100+func workflowStateText(s ermite.WorkflowState) string {
101+ if s == ermite.WorkflowState_WORKFLOW_STATE_DISABLED {
102+ return "disabled"
103+ }
104+ return "enabled"
105+}
106+
107+func toJSONWorkflow(repo string, w *ermite.Workflow) jsonWorkflow {
108+ return jsonWorkflow{Repo: repo, File: w.GetPath(), Name: w.GetName(), State: workflowStateText(w.GetState())}
109+}
110+
111+func newWorkflowListCmd(env *cliEnv) *cobra.Command {
112+ cmd := &cobra.Command{
113+ Use: "list <repo>",
114+ Short: "List the workflows of a repository and whether they are enabled",
115+ Long: "List the workflow files of .github/workflows on the default branch with their state, and the disabled\n" +
116+ "workflows whose file is absent there (a disabled workflow is listed even in an empty repository), ordered\n" +
117+ "by file. Needs the repo:read scope for a private repository." + jsonWorkflowHelp + "\n--json prints an array of workflows.",
118+ Example: " ermite workflow list app\n ermite workflow list app --json",
119+ Args: cobra.ExactArgs(1),
120+ ValidArgsFunction: completeRepoNames(env),
121+ }
122+ asJSON := addJSONFlag(cmd)
123+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
124+ c, err := env.client(cmd)
125+ if err != nil {
126+ return err
127+ }
128+ res, err := c.Runs().ListWorkflows(env.ctx(cmd), connect.NewRequest(&ermite.ListWorkflowsRequest{Repo: args[0]}))
129+ if err != nil {
130+ return c.Describe(err)
131+ }
132+ list := res.Msg.GetWorkflows()
133+ if *asJSON {
134+ out := make([]jsonWorkflow, 0, len(list))
135+ for _, w := range list {
136+ out = append(out, toJSONWorkflow(args[0], w))
137+ }
138+ return writeJSON(cmd.OutOrStdout(), out)
139+ }
140+ if len(list) == 0 {
141+ noticef(cmd, "no workflows")
142+ return nil
143+ }
144+ rows := make([][]string, 0, len(list))
145+ for _, w := range list {
146+ rows = append(rows, []string{w.GetPath(), w.GetName(), workflowStateText(w.GetState())})
147+ }
148+ return table(cmd.OutOrStdout(), []string{"FILE", "NAME", "STATE"}, rows)
149+ }
150+ return cmd
151+}
152+
153+func newWorkflowDisableCmd(env *cliEnv) *cobra.Command {
154+ cmd := &cobra.Command{
155+ Use: "disable <repo> <file>",
156+ Short: "Stop a workflow from starting runs",
157+ Long: "Disable a workflow file of a repository, like `gh workflow disable`: no event (push, pull_request,\n" +
158+ "pull_request_target, schedule, workflow_run, create, delete, workflow_dispatch) starts a run of it any more.\n" +
159+ "Runs already started go on. The state belongs to the repository and to the file path, not to a branch. The\n" +
160+ "file is its path or its file name (release.yml) and does not have to exist: disable it right after\n" +
161+ "`ermite repo create`, before the first push. Disabling a disabled workflow changes nothing. Undo with\n" +
162+ "`ermite workflow enable`. Needs the repo:admin scope." + jsonWorkflowHelp,
163+ Example: " ermite workflow disable app release.yml\n ermite workflow disable app .github/workflows/codeql.yml",
164+ Args: cobra.ExactArgs(2),
165+ ValidArgsFunction: completeRepoNames(env),
166+ }
167+ asJSON := addJSONFlag(cmd)
168+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
169+ c, err := env.client(cmd)
170+ if err != nil {
171+ return err
172+ }
173+ res, err := c.Runs().DisableWorkflow(env.ctx(cmd), connect.NewRequest(&ermite.DisableWorkflowRequest{Repo: args[0], Workflow: args[1]}))
174+ if err != nil {
175+ return c.Describe(err)
176+ }
177+ return printWorkflowState(cmd, *asJSON, args[0], res.Msg.GetWorkflow(), "disabled")
178+ }
179+ return cmd
180+}
181+
182+func newWorkflowEnableCmd(env *cliEnv) *cobra.Command {
183+ cmd := &cobra.Command{
184+ Use: "enable <repo> <file>",
185+ Short: "Let a disabled workflow start runs again",
186+ Long: "Enable a workflow file of a repository, like `gh workflow enable`. The events that happened while it was\n" +
187+ "disabled are not replayed. Enabling a workflow that is not disabled changes nothing. The file is its path\n" +
188+ "or its file name. Needs the repo:admin scope." + jsonWorkflowHelp,
189+ Example: " ermite workflow enable app release.yml",
190+ Args: cobra.ExactArgs(2),
191+ ValidArgsFunction: completeRepoNames(env),
192+ }
193+ asJSON := addJSONFlag(cmd)
194+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
195+ c, err := env.client(cmd)
196+ if err != nil {
197+ return err
198+ }
199+ res, err := c.Runs().EnableWorkflow(env.ctx(cmd), connect.NewRequest(&ermite.EnableWorkflowRequest{Repo: args[0], Workflow: args[1]}))
200+ if err != nil {
201+ return c.Describe(err)
202+ }
203+ return printWorkflowState(cmd, *asJSON, args[0], res.Msg.GetWorkflow(), "enabled")
204+ }
205+ return cmd
206+}
207+
208+func printWorkflowState(cmd *cobra.Command, asJSON bool, repo string, w *ermite.Workflow, state string) error {
209+ if asJSON {
210+ return writeJSON(cmd.OutOrStdout(), toJSONWorkflow(repo, w))
211+ }
212+ noticef(cmd, "%s %s in %s", state, clean(w.GetPath()), clean(repo))
213+ return nil
214+}
M internal/app/forge.go +6 -5
@@ -277,11 +277,12 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
277277 Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),
278278 Proxy: gitremote.ProxyEnv(os.Getenv),
279279 })),
280- Tokens: store.Tokens(),
281- RefTips: store.CI(),
282- Settings: f.Settings,
283- Workflows: opts.workflows(githubraw.New(githubraw.Config{})),
284- OIDC: f.OIDC,
280+ Tokens: store.Tokens(),
281+ RefTips: store.CI(),
282+ WorkflowState: store.CI(),
283+ Settings: f.Settings,
284+ Workflows: opts.workflows(githubraw.New(githubraw.Config{})),
285+ OIDC: f.OIDC,
285286 }
286287 artifacts(&ciCfg, cfg, store)
287288 f.pullRequests(&ciCfg, store, git, committer)
@@ -277,11 +277,12 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
277 Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),277 Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),
278 Proxy: gitremote.ProxyEnv(os.Getenv),278 Proxy: gitremote.ProxyEnv(os.Getenv),
279 })),279 })),
280- Tokens: store.Tokens(),280+ Tokens: store.Tokens(),
281- RefTips: store.CI(),281+ RefTips: store.CI(),
282- Settings: f.Settings,282+ WorkflowState: store.CI(),
283- Workflows: opts.workflows(githubraw.New(githubraw.Config{})),283+ Settings: f.Settings,
284- OIDC: f.OIDC,284+ Workflows: opts.workflows(githubraw.New(githubraw.Config{})),
285+ OIDC: f.OIDC,
285 }286 }
286 artifacts(&ciCfg, cfg, store)287 artifacts(&ciCfg, cfg, store)
287 f.pullRequests(&ciCfg, store, git, committer)288 f.pullRequests(&ciCfg, store, git, committer)
M internal/app/main_test.go +13 -1
@@ -116,8 +116,20 @@ func baseEnv() []string {
116116 return []string{"PATH=" + os.Getenv("PATH")}
117117 }
118118
119+// isolatedEnv is the environment of a CLI run that must not see the user's
120+// ERMITE_URL, ERMITE_TOKEN or config file: a version command with a server URL
121+// also asks the server and prints its version.
122+func isolatedEnv(t *testing.T) []string {
123+ t.Helper()
124+ cfg := filepath.Join(t.TempDir(), "config")
125+ require.NoError(t, os.WriteFile(cfg, nil, 0o600))
126+ return append(baseEnv(), "ERMITE_CONFIG="+cfg, "HOME="+t.TempDir(), "XDG_CONFIG_HOME="+t.TempDir())
127+}
128+
119129 func TestVersion(t *testing.T) {
120- out, err := exec.Command(binary(t), "version").Output()
130+ cmd := exec.Command(binary(t), "version")
131+ cmd.Env = isolatedEnv(t)
132+ out, err := cmd.Output()
121133 require.NoError(t, err)
122134 assert.True(t, strings.HasPrefix(string(out), "ermite "), string(out))
123135 assert.Equal(t, 1, strings.Count(string(out), "\n"))
@@ -116,8 +116,20 @@ func baseEnv() []string {
116 return []string{"PATH=" + os.Getenv("PATH")}116 return []string{"PATH=" + os.Getenv("PATH")}
117 }117 }
118 118
119+// isolatedEnv is the environment of a CLI run that must not see the user's
120+// ERMITE_URL, ERMITE_TOKEN or config file: a version command with a server URL
121+// also asks the server and prints its version.
122+func isolatedEnv(t *testing.T) []string {
123+ t.Helper()
124+ cfg := filepath.Join(t.TempDir(), "config")
125+ require.NoError(t, os.WriteFile(cfg, nil, 0o600))
126+ return append(baseEnv(), "ERMITE_CONFIG="+cfg, "HOME="+t.TempDir(), "XDG_CONFIG_HOME="+t.TempDir())
127+}
128+
119 func TestVersion(t *testing.T) {129 func TestVersion(t *testing.T) {
120- out, err := exec.Command(binary(t), "version").Output()130+ cmd := exec.Command(binary(t), "version")
131+ cmd.Env = isolatedEnv(t)
132+ out, err := cmd.Output()
121 require.NoError(t, err)133 require.NoError(t, err)
122 assert.True(t, strings.HasPrefix(string(out), "ermite "), string(out))134 assert.True(t, strings.HasPrefix(string(out), "ermite "), string(out))
123 assert.Equal(t, 1, strings.Count(string(out), "\n"))135 assert.Equal(t, 1, strings.Count(string(out), "\n"))
A internal/domain/ci_workflow.go +55 -0
@@ -0,0 +1,55 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "path"
9+ "strings"
10+ "unicode"
11+)
12+
13+// WorkflowDir is the only directory the workflows of a repository are read
14+// from (ADR 0019: no ermite dialect).
15+const WorkflowDir = ".github/workflows"
16+
17+// maxWorkflowFileName bounds the file name of a workflow, as a file name of
18+// Git trees is bounded on the usual file systems.
19+const maxWorkflowFileName = 255
20+
21+// ErrWorkflowDisabled is returned by the store when a run is created for a
22+// workflow the repository disabled. It is checked in the very transaction that
23+// creates the run, so no disable can slip in between a check and the creation.
24+var ErrWorkflowDisabled = errors.New("workflow is disabled")
25+
26+// ParseWorkflowPath normalizes the name of a workflow file, as `gh workflow`
27+// takes it: either its file name ("release.yml") or its path
28+// (".github/workflows/release.yml"). It returns the canonical path, and
29+// refuses every other spelling of it ("./.github/workflows/x.yml",
30+// ".github/workflows//x.yml", ".github/workflows/sub/../x.yml"), so one file has
31+// one key. Only the shape is checked, never whether the file exists: the state
32+// of a workflow can be set on a repository that is empty or that does not hold
33+// the file yet. A file in a subdirectory, a name that does not end in .yml or
34+// .yaml, and a name with a control character or a backslash are ErrInvalid.
35+func ParseWorkflowPath(s string) (string, error) {
36+ s = strings.TrimSpace(s)
37+ if s == "" {
38+ return "", fmt.Errorf("%w: the workflow file is required", ErrInvalid)
39+ }
40+ if !strings.Contains(s, "/") {
41+ s = WorkflowDir + "/" + s
42+ }
43+ name, ok := strings.CutPrefix(s, WorkflowDir+"/")
44+ ext := strings.ToLower(path.Ext(name))
45+ switch {
46+ case !ok,
47+ strings.Contains(name, "/"),
48+ ext != ".yml" && ext != ".yaml",
49+ strings.TrimSuffix(name, path.Ext(name)) == "",
50+ len(name) > maxWorkflowFileName,
51+ strings.ContainsFunc(name, func(r rune) bool { return unicode.IsControl(r) || r == '\\' }):
52+ return "", fmt.Errorf("%w: %q is not a workflow file of %s (a file name or the path %s/<file>, ending in .yml or .yaml)", ErrInvalid, s, WorkflowDir, WorkflowDir)
53+ }
54+ return s, nil
55+}
@@ -0,0 +1,55 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "path"
9+ "strings"
10+ "unicode"
11+)
12+
13+// WorkflowDir is the only directory the workflows of a repository are read
14+// from (ADR 0019: no ermite dialect).
15+const WorkflowDir = ".github/workflows"
16+
17+// maxWorkflowFileName bounds the file name of a workflow, as a file name of
18+// Git trees is bounded on the usual file systems.
19+const maxWorkflowFileName = 255
20+
21+// ErrWorkflowDisabled is returned by the store when a run is created for a
22+// workflow the repository disabled. It is checked in the very transaction that
23+// creates the run, so no disable can slip in between a check and the creation.
24+var ErrWorkflowDisabled = errors.New("workflow is disabled")
25+
26+// ParseWorkflowPath normalizes the name of a workflow file, as `gh workflow`
27+// takes it: either its file name ("release.yml") or its path
28+// (".github/workflows/release.yml"). It returns the canonical path, and
29+// refuses every other spelling of it ("./.github/workflows/x.yml",
30+// ".github/workflows//x.yml", ".github/workflows/sub/../x.yml"), so one file has
31+// one key. Only the shape is checked, never whether the file exists: the state
32+// of a workflow can be set on a repository that is empty or that does not hold
33+// the file yet. A file in a subdirectory, a name that does not end in .yml or
34+// .yaml, and a name with a control character or a backslash are ErrInvalid.
35+func ParseWorkflowPath(s string) (string, error) {
36+ s = strings.TrimSpace(s)
37+ if s == "" {
38+ return "", fmt.Errorf("%w: the workflow file is required", ErrInvalid)
39+ }
40+ if !strings.Contains(s, "/") {
41+ s = WorkflowDir + "/" + s
42+ }
43+ name, ok := strings.CutPrefix(s, WorkflowDir+"/")
44+ ext := strings.ToLower(path.Ext(name))
45+ switch {
46+ case !ok,
47+ strings.Contains(name, "/"),
48+ ext != ".yml" && ext != ".yaml",
49+ strings.TrimSuffix(name, path.Ext(name)) == "",
50+ len(name) > maxWorkflowFileName,
51+ strings.ContainsFunc(name, func(r rune) bool { return unicode.IsControl(r) || r == '\\' }):
52+ return "", fmt.Errorf("%w: %q is not a workflow file of %s (a file name or the path %s/<file>, ending in .yml or .yaml)", ErrInvalid, s, WorkflowDir, WorkflowDir)
53+ }
54+ return s, nil
55+}
A internal/domain/ci_workflow_test.go +34 -0
@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestParseWorkflowPath(t *testing.T) {
13+ for in, want := range map[string]string{
14+ "release.yml": ".github/workflows/release.yml",
15+ " release.yaml ": ".github/workflows/release.yaml",
16+ ".github/workflows/release.yml": ".github/workflows/release.yml",
17+ ".github/workflows/a.b-c_d.yaml": ".github/workflows/a.b-c_d.yaml",
18+ } {
19+ got, err := ParseWorkflowPath(in)
20+ require.NoError(t, err, in)
21+ assert.Equal(t, want, got, in)
22+ }
23+ for _, in := range []string{
24+ "", " ", "release", "release.txt", ".yml", ".github/workflows/.yml",
25+ "sub/release.yml", ".github/workflows/sub/release.yml", ".github/workflows/../release.yml",
26+ "/.github/workflows/release.yml", ".github/other/release.yml",
27+ ".github/workflows//release.yml", "./.github/workflows/release.yml", ".github/workflows/sub/../release.yml",
28+ ".github/workflows/./release.yml", ".github//workflows/release.yml", "github/workflows/release.yml",
29+ ".github/workflows/release.yml/", "..", "../release.yml", ".github/workflows/..", ".github/workflows/.", "x\\y.yml", "a\x00.yml", ".github/workflows/",
30+ } {
31+ _, err := ParseWorkflowPath(in)
32+ assert.ErrorIs(t, err, ErrInvalid, "%q", in)
33+ }
34+}
@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestParseWorkflowPath(t *testing.T) {
13+ for in, want := range map[string]string{
14+ "release.yml": ".github/workflows/release.yml",
15+ " release.yaml ": ".github/workflows/release.yaml",
16+ ".github/workflows/release.yml": ".github/workflows/release.yml",
17+ ".github/workflows/a.b-c_d.yaml": ".github/workflows/a.b-c_d.yaml",
18+ } {
19+ got, err := ParseWorkflowPath(in)
20+ require.NoError(t, err, in)
21+ assert.Equal(t, want, got, in)
22+ }
23+ for _, in := range []string{
24+ "", " ", "release", "release.txt", ".yml", ".github/workflows/.yml",
25+ "sub/release.yml", ".github/workflows/sub/release.yml", ".github/workflows/../release.yml",
26+ "/.github/workflows/release.yml", ".github/other/release.yml",
27+ ".github/workflows//release.yml", "./.github/workflows/release.yml", ".github/workflows/sub/../release.yml",
28+ ".github/workflows/./release.yml", ".github//workflows/release.yml", "github/workflows/release.yml",
29+ ".github/workflows/release.yml/", "..", "../release.yml", ".github/workflows/..", ".github/workflows/.", "x\\y.yml", "a\x00.yml", ".github/workflows/",
30+ } {
31+ _, err := ParseWorkflowPath(in)
32+ assert.ErrorIs(t, err, ErrInvalid, "%q", in)
33+ }
34+}
M internal/ports/ci.go +43 -2
@@ -95,6 +95,21 @@ type CIRefTipStore interface {
9595 DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string) error
9696 }
9797
98+// CIWorkflowStore keeps, per repository, the workflow files that are disabled
99+// (`ermite workflow disable`). Paths are normalized (domain.ParseWorkflowPath);
100+// the file does not have to exist. The records go with the repository.
101+// *kvstore.CI implements it.
102+type CIWorkflowStore interface {
103+ // DisableWorkflow records the path as disabled; one already disabled is
104+ // left as it is. A repository that does not exist is domain.ErrNotFound.
105+ DisableWorkflow(ctx context.Context, repo domain.RepoName, path string, at time.Time) error
106+ // EnableWorkflow removes the record; a path that is not disabled is not an
107+ // error.
108+ EnableWorkflow(ctx context.Context, repo domain.RepoName, path string) error
109+ // DisabledWorkflows returns the disabled paths of the repository, sorted.
110+ DisabledWorkflows(ctx context.Context, repo domain.RepoName) ([]string, error)
111+}
112+
98113 // CIStore keeps CI runs, jobs and checks. The store derives its own indexes
99114 // (the queue of ready jobs, the active jobs, the open runs, the job tokens)
100115 // from the records it writes.
@@ -102,8 +117,12 @@ type CIStore interface {
102117 // CreateRun takes the next run id and the next number of the workflow in
103118 // the repository (both never reused), calls build with them and stores
104119 // the draft it returns, in one batch that fails with domain.ErrNotFound
105- // once the repository is deleted. The records come back with their
106- // versions.
120+ // once the repository is deleted, and with domain.ErrWorkflowDisabled when
121+ // the repository disabled workflowPath (CIWorkflowStore) when the batch
122+ // applies: the state is checked in that transaction, not before it, so a
123+ // disable can never be overtaken, whatever event started the run and
124+ // whether the run is a failed one for an invalid file or not. The records
125+ // come back with their versions.
107126 CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) CIRunDraft) (CIRunDraft, error)
108127 // Apply writes the records in one batch and returns the version they all
109128 // have afterwards.
@@ -285,6 +304,16 @@ type DispatchParams struct {
285304 Inputs map[string]string
286305 }
287306
307+// WorkflowInfo is a workflow file of a repository and whether it starts runs.
308+type WorkflowInfo struct {
309+ // Path is ".github/workflows/<file>".
310+ Path string
311+ // Name is the "name:" of the workflow at the tip of the default branch (the
312+ // path when it has none); empty when that file is absent or not a workflow.
313+ Name string
314+ Disabled bool
315+}
316+
288317 // WorkflowTrigger starts the workflows that match pushed refs. OnPush only
289318 // queues the work and returns: planning happens in the background and its
290319 // errors never reach the push.
@@ -721,6 +750,18 @@ type RunService interface {
721750 // permission actions: write. The run is returned queued.
722751 DispatchWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, params DispatchParams) (domain.CIRun, error)
723752 ListChecks(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ChecksResult, error)
753+ // ListWorkflows returns the workflow files at the tip of the default branch
754+ // and the disabled ones whatever the branch holds, ordered by path. Reading
755+ // the repository is enough.
756+ ListWorkflows(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]WorkflowInfo, error)
757+ // DisabledWorkflows returns the paths of the disabled workflows, sorted,
758+ // without reading any file: the cheap form of ListWorkflows for the pages.
759+ DisabledWorkflows(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]string, error)
760+ // DisableWorkflow stops the workflow (its file name or path; it does not
761+ // have to exist) from starting runs for any event. EnableWorkflow undoes it.
762+ // Both need repo:admin on the repository and are idempotent.
763+ DisableWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (WorkflowInfo, error)
764+ EnableWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (WorkflowInfo, error)
724765 // ListAnnotations returns the annotations of a run attempt (0 for the
725766 // latest), of one job when jobID is not empty. Each job gives those of the
726767 // attempt in which it executed.
@@ -95,6 +95,21 @@ type CIRefTipStore interface {
95 DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string) error95 DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string) error
96 }96 }
97 97
98+// CIWorkflowStore keeps, per repository, the workflow files that are disabled
99+// (`ermite workflow disable`). Paths are normalized (domain.ParseWorkflowPath);
100+// the file does not have to exist. The records go with the repository.
101+// *kvstore.CI implements it.
102+type CIWorkflowStore interface {
103+ // DisableWorkflow records the path as disabled; one already disabled is
104+ // left as it is. A repository that does not exist is domain.ErrNotFound.
105+ DisableWorkflow(ctx context.Context, repo domain.RepoName, path string, at time.Time) error
106+ // EnableWorkflow removes the record; a path that is not disabled is not an
107+ // error.
108+ EnableWorkflow(ctx context.Context, repo domain.RepoName, path string) error
109+ // DisabledWorkflows returns the disabled paths of the repository, sorted.
110+ DisabledWorkflows(ctx context.Context, repo domain.RepoName) ([]string, error)
111+}
112+
98 // CIStore keeps CI runs, jobs and checks. The store derives its own indexes113 // CIStore keeps CI runs, jobs and checks. The store derives its own indexes
99 // (the queue of ready jobs, the active jobs, the open runs, the job tokens)114 // (the queue of ready jobs, the active jobs, the open runs, the job tokens)
100 // from the records it writes.115 // from the records it writes.
@@ -102,8 +117,12 @@ type CIStore interface {
102 // CreateRun takes the next run id and the next number of the workflow in117 // CreateRun takes the next run id and the next number of the workflow in
103 // the repository (both never reused), calls build with them and stores118 // the repository (both never reused), calls build with them and stores
104 // the draft it returns, in one batch that fails with domain.ErrNotFound119 // the draft it returns, in one batch that fails with domain.ErrNotFound
105- // once the repository is deleted. The records come back with their120+ // once the repository is deleted, and with domain.ErrWorkflowDisabled when
106- // versions.121+ // the repository disabled workflowPath (CIWorkflowStore) when the batch
122+ // applies: the state is checked in that transaction, not before it, so a
123+ // disable can never be overtaken, whatever event started the run and
124+ // whether the run is a failed one for an invalid file or not. The records
125+ // come back with their versions.
107 CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) CIRunDraft) (CIRunDraft, error)126 CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) CIRunDraft) (CIRunDraft, error)
108 // Apply writes the records in one batch and returns the version they all127 // Apply writes the records in one batch and returns the version they all
109 // have afterwards.128 // have afterwards.
@@ -285,6 +304,16 @@ type DispatchParams struct {
285 Inputs map[string]string304 Inputs map[string]string
286 }305 }
287 306
307+// WorkflowInfo is a workflow file of a repository and whether it starts runs.
308+type WorkflowInfo struct {
309+ // Path is ".github/workflows/<file>".
310+ Path string
311+ // Name is the "name:" of the workflow at the tip of the default branch (the
312+ // path when it has none); empty when that file is absent or not a workflow.
313+ Name string
314+ Disabled bool
315+}
316+
288 // WorkflowTrigger starts the workflows that match pushed refs. OnPush only317 // WorkflowTrigger starts the workflows that match pushed refs. OnPush only
289 // queues the work and returns: planning happens in the background and its318 // queues the work and returns: planning happens in the background and its
290 // errors never reach the push.319 // errors never reach the push.
@@ -721,6 +750,18 @@ type RunService interface {
721 // permission actions: write. The run is returned queued.750 // permission actions: write. The run is returned queued.
722 DispatchWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, params DispatchParams) (domain.CIRun, error)751 DispatchWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, params DispatchParams) (domain.CIRun, error)
723 ListChecks(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ChecksResult, error)752 ListChecks(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ChecksResult, error)
753+ // ListWorkflows returns the workflow files at the tip of the default branch
754+ // and the disabled ones whatever the branch holds, ordered by path. Reading
755+ // the repository is enough.
756+ ListWorkflows(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]WorkflowInfo, error)
757+ // DisabledWorkflows returns the paths of the disabled workflows, sorted,
758+ // without reading any file: the cheap form of ListWorkflows for the pages.
759+ DisabledWorkflows(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]string, error)
760+ // DisableWorkflow stops the workflow (its file name or path; it does not
761+ // have to exist) from starting runs for any event. EnableWorkflow undoes it.
762+ // Both need repo:admin on the repository and are idempotent.
763+ DisableWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (WorkflowInfo, error)
764+ EnableWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (WorkflowInfo, error)
724 // ListAnnotations returns the annotations of a run attempt (0 for the765 // ListAnnotations returns the annotations of a run attempt (0 for the
725 // latest), of one job when jobID is not empty. Each job gives those of the766 // latest), of one job when jobID is not empty. Each job gives those of the
726 // attempt in which it executed.767 // attempt in which it executed.
A internal/ports/mocks/CIWorkflowStore.go +249 -0
@@ -0,0 +1,249 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "time"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewCIWorkflowStore creates a new instance of CIWorkflowStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewCIWorkflowStore(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *CIWorkflowStore {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &CIWorkflowStore{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// CIWorkflowStore is an autogenerated mock type for the CIWorkflowStore type
39+type CIWorkflowStore struct {
40+ mock.Mock
41+}
42+
43+type CIWorkflowStore_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *CIWorkflowStore) EXPECT() *CIWorkflowStore_Expecter {
48+ return &CIWorkflowStore_Expecter{mock: &_m.Mock}
49+}
50+
51+// DisableWorkflow provides a mock function for the type CIWorkflowStore
52+func (_mock *CIWorkflowStore) DisableWorkflow(ctx context.Context, repo domain.RepoName, path string, at time.Time) error {
53+ ret := _mock.Called(ctx, repo, path, at)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for DisableWorkflow")
57+ }
58+
59+ var r0 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, time.Time) error); ok {
61+ r0 = returnFunc(ctx, repo, path, at)
62+ } else {
63+ r0 = ret.Error(0)
64+ }
65+ return r0
66+}
67+
68+// CIWorkflowStore_DisableWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisableWorkflow'
69+type CIWorkflowStore_DisableWorkflow_Call struct {
70+ *mock.Call
71+}
72+
73+// DisableWorkflow is a helper method to define mock.On call
74+// - ctx context.Context
75+// - repo domain.RepoName
76+// - path string
77+// - at time.Time
78+func (_e *CIWorkflowStore_Expecter) DisableWorkflow(ctx any, repo any, path any, at any) *CIWorkflowStore_DisableWorkflow_Call {
79+ return &CIWorkflowStore_DisableWorkflow_Call{Call: _e.mock.On("DisableWorkflow", ctx, repo, path, at)}
80+}
81+
82+func (_c *CIWorkflowStore_DisableWorkflow_Call) Run(run func(ctx context.Context, repo domain.RepoName, path string, at time.Time)) *CIWorkflowStore_DisableWorkflow_Call {
83+ _c.Call.Run(func(args mock.Arguments) {
84+ var arg0 context.Context
85+ if args[0] != nil {
86+ arg0 = args[0].(context.Context)
87+ }
88+ var arg1 domain.RepoName
89+ if args[1] != nil {
90+ arg1 = args[1].(domain.RepoName)
91+ }
92+ var arg2 string
93+ if args[2] != nil {
94+ arg2 = args[2].(string)
95+ }
96+ var arg3 time.Time
97+ if args[3] != nil {
98+ arg3 = args[3].(time.Time)
99+ }
100+ run(
101+ arg0,
102+ arg1,
103+ arg2,
104+ arg3,
105+ )
106+ })
107+ return _c
108+}
109+
110+func (_c *CIWorkflowStore_DisableWorkflow_Call) Return(err error) *CIWorkflowStore_DisableWorkflow_Call {
111+ _c.Call.Return(err)
112+ return _c
113+}
114+
115+func (_c *CIWorkflowStore_DisableWorkflow_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, path string, at time.Time) error) *CIWorkflowStore_DisableWorkflow_Call {
116+ _c.Call.Return(run)
117+ return _c
118+}
119+
120+// DisabledWorkflows provides a mock function for the type CIWorkflowStore
121+func (_mock *CIWorkflowStore) DisabledWorkflows(ctx context.Context, repo domain.RepoName) ([]string, error) {
122+ ret := _mock.Called(ctx, repo)
123+
124+ if len(ret) == 0 {
125+ panic("no return value specified for DisabledWorkflows")
126+ }
127+
128+ var r0 []string
129+ var r1 error
130+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) ([]string, error)); ok {
131+ return returnFunc(ctx, repo)
132+ }
133+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) []string); ok {
134+ r0 = returnFunc(ctx, repo)
135+ } else {
136+ if ret.Get(0) != nil {
137+ r0 = ret.Get(0).([]string)
138+ }
139+ }
140+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
141+ r1 = returnFunc(ctx, repo)
142+ } else {
143+ r1 = ret.Error(1)
144+ }
145+ return r0, r1
146+}
147+
148+// CIWorkflowStore_DisabledWorkflows_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisabledWorkflows'
149+type CIWorkflowStore_DisabledWorkflows_Call struct {
150+ *mock.Call
151+}
152+
153+// DisabledWorkflows is a helper method to define mock.On call
154+// - ctx context.Context
155+// - repo domain.RepoName
156+func (_e *CIWorkflowStore_Expecter) DisabledWorkflows(ctx any, repo any) *CIWorkflowStore_DisabledWorkflows_Call {
157+ return &CIWorkflowStore_DisabledWorkflows_Call{Call: _e.mock.On("DisabledWorkflows", ctx, repo)}
158+}
159+
160+func (_c *CIWorkflowStore_DisabledWorkflows_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *CIWorkflowStore_DisabledWorkflows_Call {
161+ _c.Call.Run(func(args mock.Arguments) {
162+ var arg0 context.Context
163+ if args[0] != nil {
164+ arg0 = args[0].(context.Context)
165+ }
166+ var arg1 domain.RepoName
167+ if args[1] != nil {
168+ arg1 = args[1].(domain.RepoName)
169+ }
170+ run(
171+ arg0,
172+ arg1,
173+ )
174+ })
175+ return _c
176+}
177+
178+func (_c *CIWorkflowStore_DisabledWorkflows_Call) Return(strings []string, err error) *CIWorkflowStore_DisabledWorkflows_Call {
179+ _c.Call.Return(strings, err)
180+ return _c
181+}
182+
183+func (_c *CIWorkflowStore_DisabledWorkflows_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) ([]string, error)) *CIWorkflowStore_DisabledWorkflows_Call {
184+ _c.Call.Return(run)
185+ return _c
186+}
187+
188+// EnableWorkflow provides a mock function for the type CIWorkflowStore
189+func (_mock *CIWorkflowStore) EnableWorkflow(ctx context.Context, repo domain.RepoName, path string) error {
190+ ret := _mock.Called(ctx, repo, path)
191+
192+ if len(ret) == 0 {
193+ panic("no return value specified for EnableWorkflow")
194+ }
195+
196+ var r0 error
197+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) error); ok {
198+ r0 = returnFunc(ctx, repo, path)
199+ } else {
200+ r0 = ret.Error(0)
201+ }
202+ return r0
203+}
204+
205+// CIWorkflowStore_EnableWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnableWorkflow'
206+type CIWorkflowStore_EnableWorkflow_Call struct {
207+ *mock.Call
208+}
209+
210+// EnableWorkflow is a helper method to define mock.On call
211+// - ctx context.Context
212+// - repo domain.RepoName
213+// - path string
214+func (_e *CIWorkflowStore_Expecter) EnableWorkflow(ctx any, repo any, path any) *CIWorkflowStore_EnableWorkflow_Call {
215+ return &CIWorkflowStore_EnableWorkflow_Call{Call: _e.mock.On("EnableWorkflow", ctx, repo, path)}
216+}
217+
218+func (_c *CIWorkflowStore_EnableWorkflow_Call) Run(run func(ctx context.Context, repo domain.RepoName, path string)) *CIWorkflowStore_EnableWorkflow_Call {
219+ _c.Call.Run(func(args mock.Arguments) {
220+ var arg0 context.Context
221+ if args[0] != nil {
222+ arg0 = args[0].(context.Context)
223+ }
224+ var arg1 domain.RepoName
225+ if args[1] != nil {
226+ arg1 = args[1].(domain.RepoName)
227+ }
228+ var arg2 string
229+ if args[2] != nil {
230+ arg2 = args[2].(string)
231+ }
232+ run(
233+ arg0,
234+ arg1,
235+ arg2,
236+ )
237+ })
238+ return _c
239+}
240+
241+func (_c *CIWorkflowStore_EnableWorkflow_Call) Return(err error) *CIWorkflowStore_EnableWorkflow_Call {
242+ _c.Call.Return(err)
243+ return _c
244+}
245+
246+func (_c *CIWorkflowStore_EnableWorkflow_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, path string) error) *CIWorkflowStore_EnableWorkflow_Call {
247+ _c.Call.Return(run)
248+ return _c
249+}
@@ -0,0 +1,249 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "time"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewCIWorkflowStore creates a new instance of CIWorkflowStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewCIWorkflowStore(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *CIWorkflowStore {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &CIWorkflowStore{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// CIWorkflowStore is an autogenerated mock type for the CIWorkflowStore type
39+type CIWorkflowStore struct {
40+ mock.Mock
41+}
42+
43+type CIWorkflowStore_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *CIWorkflowStore) EXPECT() *CIWorkflowStore_Expecter {
48+ return &CIWorkflowStore_Expecter{mock: &_m.Mock}
49+}
50+
51+// DisableWorkflow provides a mock function for the type CIWorkflowStore
52+func (_mock *CIWorkflowStore) DisableWorkflow(ctx context.Context, repo domain.RepoName, path string, at time.Time) error {
53+ ret := _mock.Called(ctx, repo, path, at)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for DisableWorkflow")
57+ }
58+
59+ var r0 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, time.Time) error); ok {
61+ r0 = returnFunc(ctx, repo, path, at)
62+ } else {
63+ r0 = ret.Error(0)
64+ }
65+ return r0
66+}
67+
68+// CIWorkflowStore_DisableWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisableWorkflow'
69+type CIWorkflowStore_DisableWorkflow_Call struct {
70+ *mock.Call
71+}
72+
73+// DisableWorkflow is a helper method to define mock.On call
74+// - ctx context.Context
75+// - repo domain.RepoName
76+// - path string
77+// - at time.Time
78+func (_e *CIWorkflowStore_Expecter) DisableWorkflow(ctx any, repo any, path any, at any) *CIWorkflowStore_DisableWorkflow_Call {
79+ return &CIWorkflowStore_DisableWorkflow_Call{Call: _e.mock.On("DisableWorkflow", ctx, repo, path, at)}
80+}
81+
82+func (_c *CIWorkflowStore_DisableWorkflow_Call) Run(run func(ctx context.Context, repo domain.RepoName, path string, at time.Time)) *CIWorkflowStore_DisableWorkflow_Call {
83+ _c.Call.Run(func(args mock.Arguments) {
84+ var arg0 context.Context
85+ if args[0] != nil {
86+ arg0 = args[0].(context.Context)
87+ }
88+ var arg1 domain.RepoName
89+ if args[1] != nil {
90+ arg1 = args[1].(domain.RepoName)
91+ }
92+ var arg2 string
93+ if args[2] != nil {
94+ arg2 = args[2].(string)
95+ }
96+ var arg3 time.Time
97+ if args[3] != nil {
98+ arg3 = args[3].(time.Time)
99+ }
100+ run(
101+ arg0,
102+ arg1,
103+ arg2,
104+ arg3,
105+ )
106+ })
107+ return _c
108+}
109+
110+func (_c *CIWorkflowStore_DisableWorkflow_Call) Return(err error) *CIWorkflowStore_DisableWorkflow_Call {
111+ _c.Call.Return(err)
112+ return _c
113+}
114+
115+func (_c *CIWorkflowStore_DisableWorkflow_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, path string, at time.Time) error) *CIWorkflowStore_DisableWorkflow_Call {
116+ _c.Call.Return(run)
117+ return _c
118+}
119+
120+// DisabledWorkflows provides a mock function for the type CIWorkflowStore
121+func (_mock *CIWorkflowStore) DisabledWorkflows(ctx context.Context, repo domain.RepoName) ([]string, error) {
122+ ret := _mock.Called(ctx, repo)
123+
124+ if len(ret) == 0 {
125+ panic("no return value specified for DisabledWorkflows")
126+ }
127+
128+ var r0 []string
129+ var r1 error
130+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) ([]string, error)); ok {
131+ return returnFunc(ctx, repo)
132+ }
133+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) []string); ok {
134+ r0 = returnFunc(ctx, repo)
135+ } else {
136+ if ret.Get(0) != nil {
137+ r0 = ret.Get(0).([]string)
138+ }
139+ }
140+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
141+ r1 = returnFunc(ctx, repo)
142+ } else {
143+ r1 = ret.Error(1)
144+ }
145+ return r0, r1
146+}
147+
148+// CIWorkflowStore_DisabledWorkflows_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisabledWorkflows'
149+type CIWorkflowStore_DisabledWorkflows_Call struct {
150+ *mock.Call
151+}
152+
153+// DisabledWorkflows is a helper method to define mock.On call
154+// - ctx context.Context
155+// - repo domain.RepoName
156+func (_e *CIWorkflowStore_Expecter) DisabledWorkflows(ctx any, repo any) *CIWorkflowStore_DisabledWorkflows_Call {
157+ return &CIWorkflowStore_DisabledWorkflows_Call{Call: _e.mock.On("DisabledWorkflows", ctx, repo)}
158+}
159+
160+func (_c *CIWorkflowStore_DisabledWorkflows_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *CIWorkflowStore_DisabledWorkflows_Call {
161+ _c.Call.Run(func(args mock.Arguments) {
162+ var arg0 context.Context
163+ if args[0] != nil {
164+ arg0 = args[0].(context.Context)
165+ }
166+ var arg1 domain.RepoName
167+ if args[1] != nil {
168+ arg1 = args[1].(domain.RepoName)
169+ }
170+ run(
171+ arg0,
172+ arg1,
173+ )
174+ })
175+ return _c
176+}
177+
178+func (_c *CIWorkflowStore_DisabledWorkflows_Call) Return(strings []string, err error) *CIWorkflowStore_DisabledWorkflows_Call {
179+ _c.Call.Return(strings, err)
180+ return _c
181+}
182+
183+func (_c *CIWorkflowStore_DisabledWorkflows_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) ([]string, error)) *CIWorkflowStore_DisabledWorkflows_Call {
184+ _c.Call.Return(run)
185+ return _c
186+}
187+
188+// EnableWorkflow provides a mock function for the type CIWorkflowStore
189+func (_mock *CIWorkflowStore) EnableWorkflow(ctx context.Context, repo domain.RepoName, path string) error {
190+ ret := _mock.Called(ctx, repo, path)
191+
192+ if len(ret) == 0 {
193+ panic("no return value specified for EnableWorkflow")
194+ }
195+
196+ var r0 error
197+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) error); ok {
198+ r0 = returnFunc(ctx, repo, path)
199+ } else {
200+ r0 = ret.Error(0)
201+ }
202+ return r0
203+}
204+
205+// CIWorkflowStore_EnableWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnableWorkflow'
206+type CIWorkflowStore_EnableWorkflow_Call struct {
207+ *mock.Call
208+}
209+
210+// EnableWorkflow is a helper method to define mock.On call
211+// - ctx context.Context
212+// - repo domain.RepoName
213+// - path string
214+func (_e *CIWorkflowStore_Expecter) EnableWorkflow(ctx any, repo any, path any) *CIWorkflowStore_EnableWorkflow_Call {
215+ return &CIWorkflowStore_EnableWorkflow_Call{Call: _e.mock.On("EnableWorkflow", ctx, repo, path)}
216+}
217+
218+func (_c *CIWorkflowStore_EnableWorkflow_Call) Run(run func(ctx context.Context, repo domain.RepoName, path string)) *CIWorkflowStore_EnableWorkflow_Call {
219+ _c.Call.Run(func(args mock.Arguments) {
220+ var arg0 context.Context
221+ if args[0] != nil {
222+ arg0 = args[0].(context.Context)
223+ }
224+ var arg1 domain.RepoName
225+ if args[1] != nil {
226+ arg1 = args[1].(domain.RepoName)
227+ }
228+ var arg2 string
229+ if args[2] != nil {
230+ arg2 = args[2].(string)
231+ }
232+ run(
233+ arg0,
234+ arg1,
235+ arg2,
236+ )
237+ })
238+ return _c
239+}
240+
241+func (_c *CIWorkflowStore_EnableWorkflow_Call) Return(err error) *CIWorkflowStore_EnableWorkflow_Call {
242+ _c.Call.Return(err)
243+ return _c
244+}
245+
246+func (_c *CIWorkflowStore_EnableWorkflow_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, path string) error) *CIWorkflowStore_EnableWorkflow_Call {
247+ _c.Call.Return(run)
248+ return _c
249+}
M internal/ports/mocks/RunService.go +304 -0
@@ -126,6 +126,158 @@ func (_c *RunService_CancelRun_Call) RunAndReturn(run func(ctx context.Context,
126126 return _c
127127 }
128128
129+// DisableWorkflow provides a mock function for the type RunService
130+func (_mock *RunService) DisableWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (ports.WorkflowInfo, error) {
131+ ret := _mock.Called(ctx, p, repo, file)
132+
133+ if len(ret) == 0 {
134+ panic("no return value specified for DisableWorkflow")
135+ }
136+
137+ var r0 ports.WorkflowInfo
138+ var r1 error
139+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) (ports.WorkflowInfo, error)); ok {
140+ return returnFunc(ctx, p, repo, file)
141+ }
142+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) ports.WorkflowInfo); ok {
143+ r0 = returnFunc(ctx, p, repo, file)
144+ } else {
145+ r0 = ret.Get(0).(ports.WorkflowInfo)
146+ }
147+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, string) error); ok {
148+ r1 = returnFunc(ctx, p, repo, file)
149+ } else {
150+ r1 = ret.Error(1)
151+ }
152+ return r0, r1
153+}
154+
155+// RunService_DisableWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisableWorkflow'
156+type RunService_DisableWorkflow_Call struct {
157+ *mock.Call
158+}
159+
160+// DisableWorkflow is a helper method to define mock.On call
161+// - ctx context.Context
162+// - p domain.Principal
163+// - repo domain.RepoName
164+// - file string
165+func (_e *RunService_Expecter) DisableWorkflow(ctx any, p any, repo any, file any) *RunService_DisableWorkflow_Call {
166+ return &RunService_DisableWorkflow_Call{Call: _e.mock.On("DisableWorkflow", ctx, p, repo, file)}
167+}
168+
169+func (_c *RunService_DisableWorkflow_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, file string)) *RunService_DisableWorkflow_Call {
170+ _c.Call.Run(func(args mock.Arguments) {
171+ var arg0 context.Context
172+ if args[0] != nil {
173+ arg0 = args[0].(context.Context)
174+ }
175+ var arg1 domain.Principal
176+ if args[1] != nil {
177+ arg1 = args[1].(domain.Principal)
178+ }
179+ var arg2 domain.RepoName
180+ if args[2] != nil {
181+ arg2 = args[2].(domain.RepoName)
182+ }
183+ var arg3 string
184+ if args[3] != nil {
185+ arg3 = args[3].(string)
186+ }
187+ run(
188+ arg0,
189+ arg1,
190+ arg2,
191+ arg3,
192+ )
193+ })
194+ return _c
195+}
196+
197+func (_c *RunService_DisableWorkflow_Call) Return(workflowInfo ports.WorkflowInfo, err error) *RunService_DisableWorkflow_Call {
198+ _c.Call.Return(workflowInfo, err)
199+ return _c
200+}
201+
202+func (_c *RunService_DisableWorkflow_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (ports.WorkflowInfo, error)) *RunService_DisableWorkflow_Call {
203+ _c.Call.Return(run)
204+ return _c
205+}
206+
207+// DisabledWorkflows provides a mock function for the type RunService
208+func (_mock *RunService) DisabledWorkflows(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]string, error) {
209+ ret := _mock.Called(ctx, p, repo)
210+
211+ if len(ret) == 0 {
212+ panic("no return value specified for DisabledWorkflows")
213+ }
214+
215+ var r0 []string
216+ var r1 error
217+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) ([]string, error)); ok {
218+ return returnFunc(ctx, p, repo)
219+ }
220+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) []string); ok {
221+ r0 = returnFunc(ctx, p, repo)
222+ } else {
223+ if ret.Get(0) != nil {
224+ r0 = ret.Get(0).([]string)
225+ }
226+ }
227+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok {
228+ r1 = returnFunc(ctx, p, repo)
229+ } else {
230+ r1 = ret.Error(1)
231+ }
232+ return r0, r1
233+}
234+
235+// RunService_DisabledWorkflows_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisabledWorkflows'
236+type RunService_DisabledWorkflows_Call struct {
237+ *mock.Call
238+}
239+
240+// DisabledWorkflows is a helper method to define mock.On call
241+// - ctx context.Context
242+// - p domain.Principal
243+// - repo domain.RepoName
244+func (_e *RunService_Expecter) DisabledWorkflows(ctx any, p any, repo any) *RunService_DisabledWorkflows_Call {
245+ return &RunService_DisabledWorkflows_Call{Call: _e.mock.On("DisabledWorkflows", ctx, p, repo)}
246+}
247+
248+func (_c *RunService_DisabledWorkflows_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName)) *RunService_DisabledWorkflows_Call {
249+ _c.Call.Run(func(args mock.Arguments) {
250+ var arg0 context.Context
251+ if args[0] != nil {
252+ arg0 = args[0].(context.Context)
253+ }
254+ var arg1 domain.Principal
255+ if args[1] != nil {
256+ arg1 = args[1].(domain.Principal)
257+ }
258+ var arg2 domain.RepoName
259+ if args[2] != nil {
260+ arg2 = args[2].(domain.RepoName)
261+ }
262+ run(
263+ arg0,
264+ arg1,
265+ arg2,
266+ )
267+ })
268+ return _c
269+}
270+
271+func (_c *RunService_DisabledWorkflows_Call) Return(strings []string, err error) *RunService_DisabledWorkflows_Call {
272+ _c.Call.Return(strings, err)
273+ return _c
274+}
275+
276+func (_c *RunService_DisabledWorkflows_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]string, error)) *RunService_DisabledWorkflows_Call {
277+ _c.Call.Return(run)
278+ return _c
279+}
280+
129281 // DispatchWorkflow provides a mock function for the type RunService
130282 func (_mock *RunService) DispatchWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.DispatchParams) (domain.CIRun, error) {
131283 ret := _mock.Called(ctx, p, repo, params)
@@ -204,6 +356,84 @@ func (_c *RunService_DispatchWorkflow_Call) RunAndReturn(run func(ctx context.Co
204356 return _c
205357 }
206358
359+// EnableWorkflow provides a mock function for the type RunService
360+func (_mock *RunService) EnableWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (ports.WorkflowInfo, error) {
361+ ret := _mock.Called(ctx, p, repo, file)
362+
363+ if len(ret) == 0 {
364+ panic("no return value specified for EnableWorkflow")
365+ }
366+
367+ var r0 ports.WorkflowInfo
368+ var r1 error
369+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) (ports.WorkflowInfo, error)); ok {
370+ return returnFunc(ctx, p, repo, file)
371+ }
372+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) ports.WorkflowInfo); ok {
373+ r0 = returnFunc(ctx, p, repo, file)
374+ } else {
375+ r0 = ret.Get(0).(ports.WorkflowInfo)
376+ }
377+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, string) error); ok {
378+ r1 = returnFunc(ctx, p, repo, file)
379+ } else {
380+ r1 = ret.Error(1)
381+ }
382+ return r0, r1
383+}
384+
385+// RunService_EnableWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnableWorkflow'
386+type RunService_EnableWorkflow_Call struct {
387+ *mock.Call
388+}
389+
390+// EnableWorkflow is a helper method to define mock.On call
391+// - ctx context.Context
392+// - p domain.Principal
393+// - repo domain.RepoName
394+// - file string
395+func (_e *RunService_Expecter) EnableWorkflow(ctx any, p any, repo any, file any) *RunService_EnableWorkflow_Call {
396+ return &RunService_EnableWorkflow_Call{Call: _e.mock.On("EnableWorkflow", ctx, p, repo, file)}
397+}
398+
399+func (_c *RunService_EnableWorkflow_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, file string)) *RunService_EnableWorkflow_Call {
400+ _c.Call.Run(func(args mock.Arguments) {
401+ var arg0 context.Context
402+ if args[0] != nil {
403+ arg0 = args[0].(context.Context)
404+ }
405+ var arg1 domain.Principal
406+ if args[1] != nil {
407+ arg1 = args[1].(domain.Principal)
408+ }
409+ var arg2 domain.RepoName
410+ if args[2] != nil {
411+ arg2 = args[2].(domain.RepoName)
412+ }
413+ var arg3 string
414+ if args[3] != nil {
415+ arg3 = args[3].(string)
416+ }
417+ run(
418+ arg0,
419+ arg1,
420+ arg2,
421+ arg3,
422+ )
423+ })
424+ return _c
425+}
426+
427+func (_c *RunService_EnableWorkflow_Call) Return(workflowInfo ports.WorkflowInfo, err error) *RunService_EnableWorkflow_Call {
428+ _c.Call.Return(workflowInfo, err)
429+ return _c
430+}
431+
432+func (_c *RunService_EnableWorkflow_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (ports.WorkflowInfo, error)) *RunService_EnableWorkflow_Call {
433+ _c.Call.Return(run)
434+ return _c
435+}
436+
207437 // GetJob provides a mock function for the type RunService
208438 func (_mock *RunService) GetJob(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (ports.JobView, error) {
209439 ret := _mock.Called(ctx, p, repo, jobID, attempt)
@@ -861,6 +1091,80 @@ func (_c *RunService_ListRuns_Call) RunAndReturn(run func(ctx context.Context, p
8611091 return _c
8621092 }
8631093
1094+// ListWorkflows provides a mock function for the type RunService
1095+func (_mock *RunService) ListWorkflows(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]ports.WorkflowInfo, error) {
1096+ ret := _mock.Called(ctx, p, repo)
1097+
1098+ if len(ret) == 0 {
1099+ panic("no return value specified for ListWorkflows")
1100+ }
1101+
1102+ var r0 []ports.WorkflowInfo
1103+ var r1 error
1104+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) ([]ports.WorkflowInfo, error)); ok {
1105+ return returnFunc(ctx, p, repo)
1106+ }
1107+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) []ports.WorkflowInfo); ok {
1108+ r0 = returnFunc(ctx, p, repo)
1109+ } else {
1110+ if ret.Get(0) != nil {
1111+ r0 = ret.Get(0).([]ports.WorkflowInfo)
1112+ }
1113+ }
1114+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok {
1115+ r1 = returnFunc(ctx, p, repo)
1116+ } else {
1117+ r1 = ret.Error(1)
1118+ }
1119+ return r0, r1
1120+}
1121+
1122+// RunService_ListWorkflows_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListWorkflows'
1123+type RunService_ListWorkflows_Call struct {
1124+ *mock.Call
1125+}
1126+
1127+// ListWorkflows is a helper method to define mock.On call
1128+// - ctx context.Context
1129+// - p domain.Principal
1130+// - repo domain.RepoName
1131+func (_e *RunService_Expecter) ListWorkflows(ctx any, p any, repo any) *RunService_ListWorkflows_Call {
1132+ return &RunService_ListWorkflows_Call{Call: _e.mock.On("ListWorkflows", ctx, p, repo)}
1133+}
1134+
1135+func (_c *RunService_ListWorkflows_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName)) *RunService_ListWorkflows_Call {
1136+ _c.Call.Run(func(args mock.Arguments) {
1137+ var arg0 context.Context
1138+ if args[0] != nil {
1139+ arg0 = args[0].(context.Context)
1140+ }
1141+ var arg1 domain.Principal
1142+ if args[1] != nil {
1143+ arg1 = args[1].(domain.Principal)
1144+ }
1145+ var arg2 domain.RepoName
1146+ if args[2] != nil {
1147+ arg2 = args[2].(domain.RepoName)
1148+ }
1149+ run(
1150+ arg0,
1151+ arg1,
1152+ arg2,
1153+ )
1154+ })
1155+ return _c
1156+}
1157+
1158+func (_c *RunService_ListWorkflows_Call) Return(workflowInfos []ports.WorkflowInfo, err error) *RunService_ListWorkflows_Call {
1159+ _c.Call.Return(workflowInfos, err)
1160+ return _c
1161+}
1162+
1163+func (_c *RunService_ListWorkflows_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]ports.WorkflowInfo, error)) *RunService_ListWorkflows_Call {
1164+ _c.Call.Return(run)
1165+ return _c
1166+}
1167+
8641168 // RemoveRunner provides a mock function for the type RunService
8651169 func (_mock *RunService) RemoveRunner(ctx context.Context, p domain.Principal, id string) error {
8661170 ret := _mock.Called(ctx, p, id)
@@ -126,6 +126,158 @@ func (_c *RunService_CancelRun_Call) RunAndReturn(run func(ctx context.Context,
126 return _c126 return _c
127 }127 }
128 128
129+// DisableWorkflow provides a mock function for the type RunService
130+func (_mock *RunService) DisableWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (ports.WorkflowInfo, error) {
131+ ret := _mock.Called(ctx, p, repo, file)
132+
133+ if len(ret) == 0 {
134+ panic("no return value specified for DisableWorkflow")
135+ }
136+
137+ var r0 ports.WorkflowInfo
138+ var r1 error
139+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) (ports.WorkflowInfo, error)); ok {
140+ return returnFunc(ctx, p, repo, file)
141+ }
142+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) ports.WorkflowInfo); ok {
143+ r0 = returnFunc(ctx, p, repo, file)
144+ } else {
145+ r0 = ret.Get(0).(ports.WorkflowInfo)
146+ }
147+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, string) error); ok {
148+ r1 = returnFunc(ctx, p, repo, file)
149+ } else {
150+ r1 = ret.Error(1)
151+ }
152+ return r0, r1
153+}
154+
155+// RunService_DisableWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisableWorkflow'
156+type RunService_DisableWorkflow_Call struct {
157+ *mock.Call
158+}
159+
160+// DisableWorkflow is a helper method to define mock.On call
161+// - ctx context.Context
162+// - p domain.Principal
163+// - repo domain.RepoName
164+// - file string
165+func (_e *RunService_Expecter) DisableWorkflow(ctx any, p any, repo any, file any) *RunService_DisableWorkflow_Call {
166+ return &RunService_DisableWorkflow_Call{Call: _e.mock.On("DisableWorkflow", ctx, p, repo, file)}
167+}
168+
169+func (_c *RunService_DisableWorkflow_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, file string)) *RunService_DisableWorkflow_Call {
170+ _c.Call.Run(func(args mock.Arguments) {
171+ var arg0 context.Context
172+ if args[0] != nil {
173+ arg0 = args[0].(context.Context)
174+ }
175+ var arg1 domain.Principal
176+ if args[1] != nil {
177+ arg1 = args[1].(domain.Principal)
178+ }
179+ var arg2 domain.RepoName
180+ if args[2] != nil {
181+ arg2 = args[2].(domain.RepoName)
182+ }
183+ var arg3 string
184+ if args[3] != nil {
185+ arg3 = args[3].(string)
186+ }
187+ run(
188+ arg0,
189+ arg1,
190+ arg2,
191+ arg3,
192+ )
193+ })
194+ return _c
195+}
196+
197+func (_c *RunService_DisableWorkflow_Call) Return(workflowInfo ports.WorkflowInfo, err error) *RunService_DisableWorkflow_Call {
198+ _c.Call.Return(workflowInfo, err)
199+ return _c
200+}
201+
202+func (_c *RunService_DisableWorkflow_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (ports.WorkflowInfo, error)) *RunService_DisableWorkflow_Call {
203+ _c.Call.Return(run)
204+ return _c
205+}
206+
207+// DisabledWorkflows provides a mock function for the type RunService
208+func (_mock *RunService) DisabledWorkflows(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]string, error) {
209+ ret := _mock.Called(ctx, p, repo)
210+
211+ if len(ret) == 0 {
212+ panic("no return value specified for DisabledWorkflows")
213+ }
214+
215+ var r0 []string
216+ var r1 error
217+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) ([]string, error)); ok {
218+ return returnFunc(ctx, p, repo)
219+ }
220+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) []string); ok {
221+ r0 = returnFunc(ctx, p, repo)
222+ } else {
223+ if ret.Get(0) != nil {
224+ r0 = ret.Get(0).([]string)
225+ }
226+ }
227+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok {
228+ r1 = returnFunc(ctx, p, repo)
229+ } else {
230+ r1 = ret.Error(1)
231+ }
232+ return r0, r1
233+}
234+
235+// RunService_DisabledWorkflows_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisabledWorkflows'
236+type RunService_DisabledWorkflows_Call struct {
237+ *mock.Call
238+}
239+
240+// DisabledWorkflows is a helper method to define mock.On call
241+// - ctx context.Context
242+// - p domain.Principal
243+// - repo domain.RepoName
244+func (_e *RunService_Expecter) DisabledWorkflows(ctx any, p any, repo any) *RunService_DisabledWorkflows_Call {
245+ return &RunService_DisabledWorkflows_Call{Call: _e.mock.On("DisabledWorkflows", ctx, p, repo)}
246+}
247+
248+func (_c *RunService_DisabledWorkflows_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName)) *RunService_DisabledWorkflows_Call {
249+ _c.Call.Run(func(args mock.Arguments) {
250+ var arg0 context.Context
251+ if args[0] != nil {
252+ arg0 = args[0].(context.Context)
253+ }
254+ var arg1 domain.Principal
255+ if args[1] != nil {
256+ arg1 = args[1].(domain.Principal)
257+ }
258+ var arg2 domain.RepoName
259+ if args[2] != nil {
260+ arg2 = args[2].(domain.RepoName)
261+ }
262+ run(
263+ arg0,
264+ arg1,
265+ arg2,
266+ )
267+ })
268+ return _c
269+}
270+
271+func (_c *RunService_DisabledWorkflows_Call) Return(strings []string, err error) *RunService_DisabledWorkflows_Call {
272+ _c.Call.Return(strings, err)
273+ return _c
274+}
275+
276+func (_c *RunService_DisabledWorkflows_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]string, error)) *RunService_DisabledWorkflows_Call {
277+ _c.Call.Return(run)
278+ return _c
279+}
280+
129 // DispatchWorkflow provides a mock function for the type RunService281 // DispatchWorkflow provides a mock function for the type RunService
130 func (_mock *RunService) DispatchWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.DispatchParams) (domain.CIRun, error) {282 func (_mock *RunService) DispatchWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.DispatchParams) (domain.CIRun, error) {
131 ret := _mock.Called(ctx, p, repo, params)283 ret := _mock.Called(ctx, p, repo, params)
@@ -204,6 +356,84 @@ func (_c *RunService_DispatchWorkflow_Call) RunAndReturn(run func(ctx context.Co
204 return _c356 return _c
205 }357 }
206 358
359+// EnableWorkflow provides a mock function for the type RunService
360+func (_mock *RunService) EnableWorkflow(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (ports.WorkflowInfo, error) {
361+ ret := _mock.Called(ctx, p, repo, file)
362+
363+ if len(ret) == 0 {
364+ panic("no return value specified for EnableWorkflow")
365+ }
366+
367+ var r0 ports.WorkflowInfo
368+ var r1 error
369+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) (ports.WorkflowInfo, error)); ok {
370+ return returnFunc(ctx, p, repo, file)
371+ }
372+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) ports.WorkflowInfo); ok {
373+ r0 = returnFunc(ctx, p, repo, file)
374+ } else {
375+ r0 = ret.Get(0).(ports.WorkflowInfo)
376+ }
377+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, string) error); ok {
378+ r1 = returnFunc(ctx, p, repo, file)
379+ } else {
380+ r1 = ret.Error(1)
381+ }
382+ return r0, r1
383+}
384+
385+// RunService_EnableWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnableWorkflow'
386+type RunService_EnableWorkflow_Call struct {
387+ *mock.Call
388+}
389+
390+// EnableWorkflow is a helper method to define mock.On call
391+// - ctx context.Context
392+// - p domain.Principal
393+// - repo domain.RepoName
394+// - file string
395+func (_e *RunService_Expecter) EnableWorkflow(ctx any, p any, repo any, file any) *RunService_EnableWorkflow_Call {
396+ return &RunService_EnableWorkflow_Call{Call: _e.mock.On("EnableWorkflow", ctx, p, repo, file)}
397+}
398+
399+func (_c *RunService_EnableWorkflow_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, file string)) *RunService_EnableWorkflow_Call {
400+ _c.Call.Run(func(args mock.Arguments) {
401+ var arg0 context.Context
402+ if args[0] != nil {
403+ arg0 = args[0].(context.Context)
404+ }
405+ var arg1 domain.Principal
406+ if args[1] != nil {
407+ arg1 = args[1].(domain.Principal)
408+ }
409+ var arg2 domain.RepoName
410+ if args[2] != nil {
411+ arg2 = args[2].(domain.RepoName)
412+ }
413+ var arg3 string
414+ if args[3] != nil {
415+ arg3 = args[3].(string)
416+ }
417+ run(
418+ arg0,
419+ arg1,
420+ arg2,
421+ arg3,
422+ )
423+ })
424+ return _c
425+}
426+
427+func (_c *RunService_EnableWorkflow_Call) Return(workflowInfo ports.WorkflowInfo, err error) *RunService_EnableWorkflow_Call {
428+ _c.Call.Return(workflowInfo, err)
429+ return _c
430+}
431+
432+func (_c *RunService_EnableWorkflow_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, file string) (ports.WorkflowInfo, error)) *RunService_EnableWorkflow_Call {
433+ _c.Call.Return(run)
434+ return _c
435+}
436+
207 // GetJob provides a mock function for the type RunService437 // GetJob provides a mock function for the type RunService
208 func (_mock *RunService) GetJob(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (ports.JobView, error) {438 func (_mock *RunService) GetJob(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (ports.JobView, error) {
209 ret := _mock.Called(ctx, p, repo, jobID, attempt)439 ret := _mock.Called(ctx, p, repo, jobID, attempt)
@@ -861,6 +1091,80 @@ func (_c *RunService_ListRuns_Call) RunAndReturn(run func(ctx context.Context, p
861 return _c1091 return _c
862 }1092 }
863 1093
1094+// ListWorkflows provides a mock function for the type RunService
1095+func (_mock *RunService) ListWorkflows(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]ports.WorkflowInfo, error) {
1096+ ret := _mock.Called(ctx, p, repo)
1097+
1098+ if len(ret) == 0 {
1099+ panic("no return value specified for ListWorkflows")
1100+ }
1101+
1102+ var r0 []ports.WorkflowInfo
1103+ var r1 error
1104+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) ([]ports.WorkflowInfo, error)); ok {
1105+ return returnFunc(ctx, p, repo)
1106+ }
1107+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) []ports.WorkflowInfo); ok {
1108+ r0 = returnFunc(ctx, p, repo)
1109+ } else {
1110+ if ret.Get(0) != nil {
1111+ r0 = ret.Get(0).([]ports.WorkflowInfo)
1112+ }
1113+ }
1114+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok {
1115+ r1 = returnFunc(ctx, p, repo)
1116+ } else {
1117+ r1 = ret.Error(1)
1118+ }
1119+ return r0, r1
1120+}
1121+
1122+// RunService_ListWorkflows_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListWorkflows'
1123+type RunService_ListWorkflows_Call struct {
1124+ *mock.Call
1125+}
1126+
1127+// ListWorkflows is a helper method to define mock.On call
1128+// - ctx context.Context
1129+// - p domain.Principal
1130+// - repo domain.RepoName
1131+func (_e *RunService_Expecter) ListWorkflows(ctx any, p any, repo any) *RunService_ListWorkflows_Call {
1132+ return &RunService_ListWorkflows_Call{Call: _e.mock.On("ListWorkflows", ctx, p, repo)}
1133+}
1134+
1135+func (_c *RunService_ListWorkflows_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName)) *RunService_ListWorkflows_Call {
1136+ _c.Call.Run(func(args mock.Arguments) {
1137+ var arg0 context.Context
1138+ if args[0] != nil {
1139+ arg0 = args[0].(context.Context)
1140+ }
1141+ var arg1 domain.Principal
1142+ if args[1] != nil {
1143+ arg1 = args[1].(domain.Principal)
1144+ }
1145+ var arg2 domain.RepoName
1146+ if args[2] != nil {
1147+ arg2 = args[2].(domain.RepoName)
1148+ }
1149+ run(
1150+ arg0,
1151+ arg1,
1152+ arg2,
1153+ )
1154+ })
1155+ return _c
1156+}
1157+
1158+func (_c *RunService_ListWorkflows_Call) Return(workflowInfos []ports.WorkflowInfo, err error) *RunService_ListWorkflows_Call {
1159+ _c.Call.Return(workflowInfos, err)
1160+ return _c
1161+}
1162+
1163+func (_c *RunService_ListWorkflows_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName) ([]ports.WorkflowInfo, error)) *RunService_ListWorkflows_Call {
1164+ _c.Call.Return(run)
1165+ return _c
1166+}
1167+
864 // RemoveRunner provides a mock function for the type RunService1168 // RemoveRunner provides a mock function for the type RunService
865 func (_mock *RunService) RemoveRunner(ctx context.Context, p domain.Principal, id string) error {1169 func (_mock *RunService) RemoveRunner(ctx context.Context, p domain.Principal, id string) error {
866 ret := _mock.Called(ctx, p, id)1170 ret := _mock.Called(ctx, p, id)
M internal/usecase/ci.go +7 -0
@@ -167,6 +167,9 @@ type CIConfig struct {
167167 // runs and the trust of dispatched ones (ci_tips.go). Nil: they are never
168168 // trusted. Leave it unset rather than assigning a typed nil pointer.
169169 RefTips ports.CIRefTipStore
170+ // WorkflowState keeps the workflows a repository disabled (ci_workflows.go).
171+ // Nil: none is. Leave it unset rather than assigning a typed nil pointer.
172+ WorkflowState ports.CIWorkflowStore
170173 // DispatchResolved, when set, is called by DispatchWorkflow after it resolved
171174 // its ref and before it judges the trust of the run. Tests use it to move the
172175 // ref in between.
@@ -175,6 +178,10 @@ type CIConfig struct {
175178 // "requested" or "completed" event of a run were started (or none matched).
176179 // Tests use it as a barrier.
177180 RunEventHandled func(runID int64, action string)
181+ // ScheduleLoaded, when set, is called after the scheduler read the workflows
182+ // of a repository and before it caches and fires them. Tests use it to
183+ // change the disabled state in that window.
184+ ScheduleLoaded func(repo domain.RepoName)
178185 // ScheduleVisit, when set, is called with each repository the scheduler
179186 // looks at. Tests use it.
180187 ScheduleVisit func(repo domain.RepoName)
@@ -167,6 +167,9 @@ type CIConfig struct {
167 // runs and the trust of dispatched ones (ci_tips.go). Nil: they are never167 // runs and the trust of dispatched ones (ci_tips.go). Nil: they are never
168 // trusted. Leave it unset rather than assigning a typed nil pointer.168 // trusted. Leave it unset rather than assigning a typed nil pointer.
169 RefTips ports.CIRefTipStore169 RefTips ports.CIRefTipStore
170+ // WorkflowState keeps the workflows a repository disabled (ci_workflows.go).
171+ // Nil: none is. Leave it unset rather than assigning a typed nil pointer.
172+ WorkflowState ports.CIWorkflowStore
170 // DispatchResolved, when set, is called by DispatchWorkflow after it resolved173 // DispatchResolved, when set, is called by DispatchWorkflow after it resolved
171 // its ref and before it judges the trust of the run. Tests use it to move the174 // its ref and before it judges the trust of the run. Tests use it to move the
172 // ref in between.175 // ref in between.
@@ -175,6 +178,10 @@ type CIConfig struct {
175 // "requested" or "completed" event of a run were started (or none matched).178 // "requested" or "completed" event of a run were started (or none matched).
176 // Tests use it as a barrier.179 // Tests use it as a barrier.
177 RunEventHandled func(runID int64, action string)180 RunEventHandled func(runID int64, action string)
181+ // ScheduleLoaded, when set, is called after the scheduler read the workflows
182+ // of a repository and before it caches and fires them. Tests use it to
183+ // change the disabled state in that window.
184+ ScheduleLoaded func(repo domain.RepoName)
178 // ScheduleVisit, when set, is called with each repository the scheduler185 // ScheduleVisit, when set, is called with each repository the scheduler
179 // looks at. Tests use it.186 // looks at. Tests use it.
180 ScheduleVisit func(repo domain.RepoName)187 ScheduleVisit func(repo domain.RepoName)
M internal/usecase/ci_dispatch.go +12 -16
@@ -7,7 +7,6 @@ import (
77 "errors"
88 "fmt"
99 "io"
10- "path"
1110 "strings"
1211
1312 "bnema.dev/ermite/internal/domain"
@@ -41,6 +40,11 @@ func (c *CI) DispatchWorkflow(ctx context.Context, p domain.Principal, name doma
4140 if err != nil {
4241 return domain.CIRun{}, err
4342 }
43+ if off, err := c.workflowDisabled(ctx, name, wfPath); err != nil {
44+ return domain.CIRun{}, err
45+ } else if off {
46+ return domain.CIRun{}, fmt.Errorf("%w: %s is disabled (`ermite workflow enable`)", domain.ErrPreconditionFailed, wfPath)
47+ }
4448 pin, err := c.dispatchRef(ctx, name, params.Ref)
4549 if err != nil {
4650 return domain.CIRun{}, err
@@ -91,6 +95,10 @@ func (c *CI) DispatchWorkflow(ctx context.Context, p domain.Principal, name doma
9195 return domain.CIRun{}, fmt.Errorf("%w: %s", domain.ErrInvalid, problem.text)
9296 }
9397 run, err := c.launchRun(ctx, repo, t, f, wfPath, string(content), wf)
98+ if errors.Is(err, domain.ErrWorkflowDisabled) {
99+ // Disabled after the check above: the store refused the run.
100+ return domain.CIRun{}, fmt.Errorf("%w: %s is disabled (`ermite workflow enable`)", domain.ErrPreconditionFailed, wfPath)
101+ }
94102 if err != nil {
95103 return domain.CIRun{}, err
96104 }
@@ -131,21 +139,9 @@ func (c *CI) dispatchCaller(ctx context.Context, p domain.Principal, name domain
131139 }
132140
133141 // dispatchPath checks the workflow file of a dispatch: a file of
134-// .github/workflows, named by its path or by its file name.
135-func dispatchPath(s string) (string, error) {
136- s = strings.TrimSpace(s)
137- if s == "" {
138- return "", fmt.Errorf("%w: the workflow file is required", domain.ErrInvalid)
139- }
140- if !strings.Contains(s, "/") {
141- s = workflowDir + "/" + s
142- }
143- ext := strings.ToLower(path.Ext(s))
144- if path.Dir(s) != workflowDir || (ext != ".yml" && ext != ".yaml") {
145- return "", fmt.Errorf("%w: %q is not a workflow file of %s", domain.ErrInvalid, s, workflowDir)
146- }
147- return s, nil
148-}
142+// .github/workflows, named by its path or by its file name, in its canonical
143+// spelling (the one the disabled state is keyed by).
144+func dispatchPath(s string) (string, error) { return domain.ParseWorkflowPath(s) }
149145
150146 // pinnedRef is what a dispatch resolved its ref to, once: the full name, the
151147 // commit the run executes and the object the ref points to (the commit for a
@@ -7,7 +7,6 @@ import (
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9 "io"9 "io"
10- "path"
11 "strings"10 "strings"
12 11
13 "bnema.dev/ermite/internal/domain"12 "bnema.dev/ermite/internal/domain"
@@ -41,6 +40,11 @@ func (c *CI) DispatchWorkflow(ctx context.Context, p domain.Principal, name doma
41 if err != nil {40 if err != nil {
42 return domain.CIRun{}, err41 return domain.CIRun{}, err
43 }42 }
43+ if off, err := c.workflowDisabled(ctx, name, wfPath); err != nil {
44+ return domain.CIRun{}, err
45+ } else if off {
46+ return domain.CIRun{}, fmt.Errorf("%w: %s is disabled (`ermite workflow enable`)", domain.ErrPreconditionFailed, wfPath)
47+ }
44 pin, err := c.dispatchRef(ctx, name, params.Ref)48 pin, err := c.dispatchRef(ctx, name, params.Ref)
45 if err != nil {49 if err != nil {
46 return domain.CIRun{}, err50 return domain.CIRun{}, err
@@ -91,6 +95,10 @@ func (c *CI) DispatchWorkflow(ctx context.Context, p domain.Principal, name doma
91 return domain.CIRun{}, fmt.Errorf("%w: %s", domain.ErrInvalid, problem.text)95 return domain.CIRun{}, fmt.Errorf("%w: %s", domain.ErrInvalid, problem.text)
92 }96 }
93 run, err := c.launchRun(ctx, repo, t, f, wfPath, string(content), wf)97 run, err := c.launchRun(ctx, repo, t, f, wfPath, string(content), wf)
98+ if errors.Is(err, domain.ErrWorkflowDisabled) {
99+ // Disabled after the check above: the store refused the run.
100+ return domain.CIRun{}, fmt.Errorf("%w: %s is disabled (`ermite workflow enable`)", domain.ErrPreconditionFailed, wfPath)
101+ }
94 if err != nil {102 if err != nil {
95 return domain.CIRun{}, err103 return domain.CIRun{}, err
96 }104 }
@@ -131,21 +139,9 @@ func (c *CI) dispatchCaller(ctx context.Context, p domain.Principal, name domain
131 }139 }
132 140
133 // dispatchPath checks the workflow file of a dispatch: a file of141 // dispatchPath checks the workflow file of a dispatch: a file of
134-// .github/workflows, named by its path or by its file name.142+// .github/workflows, named by its path or by its file name, in its canonical
135-func dispatchPath(s string) (string, error) {143+// spelling (the one the disabled state is keyed by).
136- s = strings.TrimSpace(s)144+func dispatchPath(s string) (string, error) { return domain.ParseWorkflowPath(s) }
137- if s == "" {
138- return "", fmt.Errorf("%w: the workflow file is required", domain.ErrInvalid)
139- }
140- if !strings.Contains(s, "/") {
141- s = workflowDir + "/" + s
142- }
143- ext := strings.ToLower(path.Ext(s))
144- if path.Dir(s) != workflowDir || (ext != ".yml" && ext != ".yaml") {
145- return "", fmt.Errorf("%w: %q is not a workflow file of %s", domain.ErrInvalid, s, workflowDir)
146- }
147- return s, nil
148-}
149 145
150 // pinnedRef is what a dispatch resolved its ref to, once: the full name, the146 // pinnedRef is what a dispatch resolved its ref to, once: the full name, the
151 // commit the run executes and the object the ref points to (the commit for a147 // commit the run executes and the object the ref points to (the commit for a
M internal/usecase/ci_schedule.go +16 -1
@@ -329,7 +329,11 @@ func (c *CI) loadSchedules(ctx context.Context, repo domain.RepoName) (*schedule
329329 return &scheduleEntry{}, nil // no repository, no default branch, nothing to schedule
330330 }
331331 e := &scheduleEntry{tip: tip}
332- files, err := c.workflowFiles(ctx, repo, tip)
332+ // Every workflow, disabled or not: the cache is of the commit, not of the
333+ // state, which is read when a schedule fires (fireSchedule). A load that
334+ // raced with a disable or an enable would otherwise keep the wrong set of
335+ // workflows for as long as the tip does not move.
336+ files, err := c.listWorkflowFiles(ctx, repo, tip)
333337 if err != nil {
334338 return nil, err
335339 }
@@ -394,6 +398,9 @@ func (c *CI) scheduleRepo(ctx context.Context, repo domain.RepoName, minute time
394398 if e, err = c.loadSchedules(ctx, repo); err != nil {
395399 return fmt.Errorf("read the schedules: %w", err)
396400 }
401+ if c.cfg.ScheduleLoaded != nil {
402+ c.cfg.ScheduleLoaded(repo)
403+ }
397404 c.sched.put(repo, e)
398405 }
399406 idx, specs := dueFor(e, minute)
@@ -422,6 +429,11 @@ func (c *CI) scheduleRepo(ctx context.Context, repo domain.RepoName, minute time
422429
423430 // fireSchedule starts the run of one scheduled workflow.
424431 func (c *CI) fireSchedule(ctx context.Context, repo domain.Repo, def string, e *scheduleEntry, head domain.Commit, sw scheduledWorkflow, spec string) error {
432+ // The cache holds the disabled workflows too; the state is read now (and the
433+ // store checks it again when the run is stored).
434+ if off, err := c.workflowDisabled(ctx, repo.Name, sw.path); err != nil || off {
435+ return err
436+ }
425437 wf, perrs := parseWorkflow(sw.path, sw.yaml)
426438 if len(perrs) > 0 {
427439 return nil
@@ -442,5 +454,8 @@ func (c *CI) fireSchedule(ctx context.Context, repo domain.Repo, def string, e *
442454 return c.startInvalid(ctx, repo, t, facts, sw.path, name, problem.text)
443455 }
444456 _, err := c.launchRun(ctx, repo, t, facts, sw.path, string(sw.yaml), wf)
457+ if errors.Is(err, domain.ErrWorkflowDisabled) {
458+ return nil
459+ }
445460 return err
446461 }
@@ -329,7 +329,11 @@ func (c *CI) loadSchedules(ctx context.Context, repo domain.RepoName) (*schedule
329 return &scheduleEntry{}, nil // no repository, no default branch, nothing to schedule329 return &scheduleEntry{}, nil // no repository, no default branch, nothing to schedule
330 }330 }
331 e := &scheduleEntry{tip: tip}331 e := &scheduleEntry{tip: tip}
332- files, err := c.workflowFiles(ctx, repo, tip)332+ // Every workflow, disabled or not: the cache is of the commit, not of the
333+ // state, which is read when a schedule fires (fireSchedule). A load that
334+ // raced with a disable or an enable would otherwise keep the wrong set of
335+ // workflows for as long as the tip does not move.
336+ files, err := c.listWorkflowFiles(ctx, repo, tip)
333 if err != nil {337 if err != nil {
334 return nil, err338 return nil, err
335 }339 }
@@ -394,6 +398,9 @@ func (c *CI) scheduleRepo(ctx context.Context, repo domain.RepoName, minute time
394 if e, err = c.loadSchedules(ctx, repo); err != nil {398 if e, err = c.loadSchedules(ctx, repo); err != nil {
395 return fmt.Errorf("read the schedules: %w", err)399 return fmt.Errorf("read the schedules: %w", err)
396 }400 }
401+ if c.cfg.ScheduleLoaded != nil {
402+ c.cfg.ScheduleLoaded(repo)
403+ }
397 c.sched.put(repo, e)404 c.sched.put(repo, e)
398 }405 }
399 idx, specs := dueFor(e, minute)406 idx, specs := dueFor(e, minute)
@@ -422,6 +429,11 @@ func (c *CI) scheduleRepo(ctx context.Context, repo domain.RepoName, minute time
422 429
423 // fireSchedule starts the run of one scheduled workflow.430 // fireSchedule starts the run of one scheduled workflow.
424 func (c *CI) fireSchedule(ctx context.Context, repo domain.Repo, def string, e *scheduleEntry, head domain.Commit, sw scheduledWorkflow, spec string) error {431 func (c *CI) fireSchedule(ctx context.Context, repo domain.Repo, def string, e *scheduleEntry, head domain.Commit, sw scheduledWorkflow, spec string) error {
432+ // The cache holds the disabled workflows too; the state is read now (and the
433+ // store checks it again when the run is stored).
434+ if off, err := c.workflowDisabled(ctx, repo.Name, sw.path); err != nil || off {
435+ return err
436+ }
425 wf, perrs := parseWorkflow(sw.path, sw.yaml)437 wf, perrs := parseWorkflow(sw.path, sw.yaml)
426 if len(perrs) > 0 {438 if len(perrs) > 0 {
427 return nil439 return nil
@@ -442,5 +454,8 @@ func (c *CI) fireSchedule(ctx context.Context, repo domain.Repo, def string, e *
442 return c.startInvalid(ctx, repo, t, facts, sw.path, name, problem.text)454 return c.startInvalid(ctx, repo, t, facts, sw.path, name, problem.text)
443 }455 }
444 _, err := c.launchRun(ctx, repo, t, facts, sw.path, string(sw.yaml), wf)456 _, err := c.launchRun(ctx, repo, t, facts, sw.path, string(sw.yaml), wf)
457+ if errors.Is(err, domain.ErrWorkflowDisabled) {
458+ return nil
459+ }
445 return err460 return err
446 }461 }
M internal/usecase/ci_trigger.go +25 -3
@@ -430,8 +430,25 @@ type workflowFile struct {
430430 problem string
431431 }
432432
433-// workflowFiles reads .github/workflows/*.yml|*.yaml at the commit.
433+// workflowFiles reads .github/workflows/*.yml|*.yaml at the commit, without the
434+// workflows the repository disabled: every event that starts a run reads its
435+// workflows here, so a disabled one starts none (ci_workflows.go).
434436 func (c *CI) workflowFiles(ctx context.Context, repo domain.RepoName, commit domain.ObjectID) ([]workflowFile, error) {
437+ files, err := c.listWorkflowFiles(ctx, repo, commit)
438+ if err != nil || len(files) == 0 {
439+ return files, err
440+ }
441+ off, err := c.disabledPaths(ctx, repo)
442+ if err != nil || len(off) == 0 {
443+ return files, err
444+ }
445+ return slices.DeleteFunc(files, func(f workflowFile) bool { return slices.Contains(off, f.path) }), nil
446+}
447+
448+// listWorkflowFiles is workflowFiles with the disabled ones. The disabled state
449+// is also enforced where a run is stored (CIStore.CreateRun), which is what
450+// holds against a change made while a push is being planned.
451+func (c *CI) listWorkflowFiles(ctx context.Context, repo domain.RepoName, commit domain.ObjectID) ([]workflowFile, error) {
435452 dir, err := domain.ParseTreePath(workflowDir)
436453 if err != nil {
437454 return nil, err
@@ -512,7 +529,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f
512529 }},
513530 }
514531 })
515- if errors.Is(err, domain.ErrNotFound) || errors.Is(err, errPullClosed) {
532+ if errors.Is(err, domain.ErrNotFound) || errors.Is(err, errPullClosed) || errors.Is(err, domain.ErrWorkflowDisabled) {
516533 return nil
517534 }
518535 if err != nil {
@@ -534,11 +551,16 @@ func (c *CI) publishRun(ctx context.Context, repo domain.Repo, run domain.CIRun)
534551 // job of the file, then plans the jobs that can start.
535552 func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) error {
536553 _, err := c.launchRun(ctx, repo, t, f, wfPath, yaml, wf)
554+ if errors.Is(err, domain.ErrWorkflowDisabled) {
555+ return nil // disabled since it was read: no run (the store decides, atomically)
556+ }
537557 return err
538558 }
539559
540560 // launchRun is startRun that also returns the run it created, as it was when
541-// created (the zero run when the repository or the merge request is gone).
561+// created (the zero run when the repository or the merge request is gone). A
562+// workflow that is disabled when the run is stored gets none, whatever was
563+// checked before: the error is domain.ErrWorkflowDisabled.
542564 func (c *CI) launchRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) (domain.CIRun, error) {
543565 now := c.now()
544566 name := workflowName(wf, wfPath)
@@ -430,8 +430,25 @@ type workflowFile struct {
430 problem string430 problem string
431 }431 }
432 432
433-// workflowFiles reads .github/workflows/*.yml|*.yaml at the commit.433+// workflowFiles reads .github/workflows/*.yml|*.yaml at the commit, without the
434+// workflows the repository disabled: every event that starts a run reads its
435+// workflows here, so a disabled one starts none (ci_workflows.go).
434 func (c *CI) workflowFiles(ctx context.Context, repo domain.RepoName, commit domain.ObjectID) ([]workflowFile, error) {436 func (c *CI) workflowFiles(ctx context.Context, repo domain.RepoName, commit domain.ObjectID) ([]workflowFile, error) {
437+ files, err := c.listWorkflowFiles(ctx, repo, commit)
438+ if err != nil || len(files) == 0 {
439+ return files, err
440+ }
441+ off, err := c.disabledPaths(ctx, repo)
442+ if err != nil || len(off) == 0 {
443+ return files, err
444+ }
445+ return slices.DeleteFunc(files, func(f workflowFile) bool { return slices.Contains(off, f.path) }), nil
446+}
447+
448+// listWorkflowFiles is workflowFiles with the disabled ones. The disabled state
449+// is also enforced where a run is stored (CIStore.CreateRun), which is what
450+// holds against a change made while a push is being planned.
451+func (c *CI) listWorkflowFiles(ctx context.Context, repo domain.RepoName, commit domain.ObjectID) ([]workflowFile, error) {
435 dir, err := domain.ParseTreePath(workflowDir)452 dir, err := domain.ParseTreePath(workflowDir)
436 if err != nil {453 if err != nil {
437 return nil, err454 return nil, err
@@ -512,7 +529,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f
512 }},529 }},
513 }530 }
514 })531 })
515- if errors.Is(err, domain.ErrNotFound) || errors.Is(err, errPullClosed) {532+ if errors.Is(err, domain.ErrNotFound) || errors.Is(err, errPullClosed) || errors.Is(err, domain.ErrWorkflowDisabled) {
516 return nil533 return nil
517 }534 }
518 if err != nil {535 if err != nil {
@@ -534,11 +551,16 @@ func (c *CI) publishRun(ctx context.Context, repo domain.Repo, run domain.CIRun)
534 // job of the file, then plans the jobs that can start.551 // job of the file, then plans the jobs that can start.
535 func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) error {552 func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) error {
536 _, err := c.launchRun(ctx, repo, t, f, wfPath, yaml, wf)553 _, err := c.launchRun(ctx, repo, t, f, wfPath, yaml, wf)
554+ if errors.Is(err, domain.ErrWorkflowDisabled) {
555+ return nil // disabled since it was read: no run (the store decides, atomically)
556+ }
537 return err557 return err
538 }558 }
539 559
540 // launchRun is startRun that also returns the run it created, as it was when560 // launchRun is startRun that also returns the run it created, as it was when
541-// created (the zero run when the repository or the merge request is gone).561+// created (the zero run when the repository or the merge request is gone). A
562+// workflow that is disabled when the run is stored gets none, whatever was
563+// checked before: the error is domain.ErrWorkflowDisabled.
542 func (c *CI) launchRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) (domain.CIRun, error) {564 func (c *CI) launchRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) (domain.CIRun, error) {
543 now := c.now()565 now := c.now()
544 name := workflowName(wf, wfPath)566 name := workflowName(wf, wfPath)
M internal/usecase/ci_workflowrun.go +1 -1
@@ -244,7 +244,7 @@ func (c *CI) handleRunEvent(ctx context.Context, e runEvent) error {
244244 errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, name, problem.text))
245245 continue
246246 }
247- if _, err := c.launchRun(ctx, repo, t, facts, f.path, string(f.content), wf); err != nil && !errors.Is(err, context.Canceled) {
247+ if _, err := c.launchRun(ctx, repo, t, facts, f.path, string(f.content), wf); err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, domain.ErrWorkflowDisabled) {
248248 errs = append(errs, err)
249249 }
250250 }
@@ -244,7 +244,7 @@ func (c *CI) handleRunEvent(ctx context.Context, e runEvent) error {
244 errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, name, problem.text))244 errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, name, problem.text))
245 continue245 continue
246 }246 }
247- if _, err := c.launchRun(ctx, repo, t, facts, f.path, string(f.content), wf); err != nil && !errors.Is(err, context.Canceled) {247+ if _, err := c.launchRun(ctx, repo, t, facts, f.path, string(f.content), wf); err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, domain.ErrWorkflowDisabled) {
248 errs = append(errs, err)248 errs = append(errs, err)
249 }249 }
250 }250 }
A internal/usecase/ci_workflows.go +152 -0
@@ -0,0 +1,152 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "fmt"
8+ "slices"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// Disabled workflows (`ermite workflow disable|enable|list`, the equivalent of
15+// `gh workflow disable`). The state is per repository and per workflow file
16+// path, kept in the store apart from Git: it holds for every branch and every
17+// commit, and the file does not have to exist, so it can be set on an empty
18+// repository before the first push.
19+//
20+// A disabled workflow starts no run for any event. The workflow files are read
21+// through workflowFiles for push, pull_request, pull_request_target, create,
22+// delete, schedule and workflow_run, and that one place leaves the disabled
23+// ones out; workflow_dispatch and the firing of a cached schedule check the
24+// state themselves (the cache holds every workflow). Whatever was checked
25+// before, the store refuses to create a run for a disabled workflow in the very
26+// transaction that creates it (CIStore.CreateRun, domain.ErrWorkflowDisabled),
27+// so a disable that lands while a push, a dispatch or a schedule is being
28+// planned cannot be overtaken. A run already started is not touched, and the events that
29+// happened while a workflow was disabled are not replayed when it is enabled.
30+
31+// workflowDisabled reports whether the path is disabled; nothing is when no
32+// store is configured.
33+func (c *CI) workflowDisabled(ctx context.Context, repo domain.RepoName, wfPath string) (bool, error) {
34+ off, err := c.disabledPaths(ctx, repo)
35+ if err != nil {
36+ return false, err
37+ }
38+ return slices.Contains(off, wfPath), nil
39+}
40+
41+// disabledPaths are the disabled paths of a repository, sorted.
42+func (c *CI) disabledPaths(ctx context.Context, repo domain.RepoName) ([]string, error) {
43+ if c.cfg.WorkflowState == nil {
44+ return nil, nil
45+ }
46+ off, err := c.cfg.WorkflowState.DisabledWorkflows(ctx, repo)
47+ if err != nil {
48+ return nil, fmt.Errorf("read the disabled workflows: %w", err)
49+ }
50+ return off, nil
51+}
52+
53+// ListWorkflows returns the workflow files at the tip of the default branch and
54+// the disabled ones whatever the branch holds, ordered by path. Reading the
55+// repository is enough.
56+func (c *CI) ListWorkflows(ctx context.Context, p domain.Principal, name domain.RepoName) ([]ports.WorkflowInfo, error) {
57+ if _, err := c.readableRepo(ctx, p, name); err != nil {
58+ return nil, err
59+ }
60+ off, err := c.disabledPaths(ctx, name)
61+ if err != nil {
62+ return nil, err
63+ }
64+ byPath := map[string]*ports.WorkflowInfo{}
65+ if tip, ok := c.defaultTip(ctx, name); ok {
66+ files, err := c.listWorkflowFiles(ctx, name, tip)
67+ if err != nil {
68+ return nil, err
69+ }
70+ for _, f := range files {
71+ info := &ports.WorkflowInfo{Path: f.path}
72+ if f.problem == "" {
73+ if wf, perrs := parseWorkflow(f.path, f.content); len(perrs) == 0 && wf != nil {
74+ info.Name = workflowName(wf, f.path)
75+ }
76+ }
77+ byPath[f.path] = info
78+ }
79+ }
80+ for _, path := range off {
81+ if byPath[path] == nil {
82+ byPath[path] = &ports.WorkflowInfo{Path: path}
83+ }
84+ byPath[path].Disabled = true
85+ }
86+ out := make([]ports.WorkflowInfo, 0, len(byPath))
87+ for _, info := range byPath {
88+ out = append(out, *info)
89+ }
90+ slices.SortFunc(out, func(a, b ports.WorkflowInfo) int {
91+ switch {
92+ case a.Path < b.Path:
93+ return -1
94+ case a.Path > b.Path:
95+ return 1
96+ }
97+ return 0
98+ })
99+ return out, nil
100+}
101+
102+// DisabledWorkflows returns the disabled paths of a repository, sorted, without
103+// reading any file.
104+func (c *CI) DisabledWorkflows(ctx context.Context, p domain.Principal, name domain.RepoName) ([]string, error) {
105+ if _, err := c.readableRepo(ctx, p, name); err != nil {
106+ return nil, err
107+ }
108+ return c.disabledPaths(ctx, name)
109+}
110+
111+// DisableWorkflow stops a workflow file from starting runs. The file is named by
112+// its path or its file name and does not have to exist. It needs repo:admin on
113+// the repository (adminAccess), and is idempotent.
114+func (c *CI) DisableWorkflow(ctx context.Context, p domain.Principal, name domain.RepoName, file string) (ports.WorkflowInfo, error) {
115+ return c.setWorkflowState(ctx, p, name, file, true)
116+}
117+
118+// EnableWorkflow undoes DisableWorkflow. Same access; idempotent.
119+func (c *CI) EnableWorkflow(ctx context.Context, p domain.Principal, name domain.RepoName, file string) (ports.WorkflowInfo, error) {
120+ return c.setWorkflowState(ctx, p, name, file, false)
121+}
122+
123+func (c *CI) setWorkflowState(ctx context.Context, p domain.Principal, name domain.RepoName, file string, disable bool) (ports.WorkflowInfo, error) {
124+ repo, err := lookupRepo(ctx, c.cfg.Meta, name)
125+ if err != nil {
126+ return ports.WorkflowInfo{}, err
127+ }
128+ if err := adminAccess(p, repo); err != nil {
129+ return ports.WorkflowInfo{}, err
130+ }
131+ wfPath, err := domain.ParseWorkflowPath(file)
132+ if err != nil {
133+ return ports.WorkflowInfo{}, err
134+ }
135+ if c.cfg.WorkflowState == nil {
136+ return ports.WorkflowInfo{}, fmt.Errorf("%w: this server keeps no workflow state", domain.ErrPreconditionFailed)
137+ }
138+ if disable {
139+ err = c.cfg.WorkflowState.DisableWorkflow(ctx, name, wfPath, c.now())
140+ } else {
141+ err = c.cfg.WorkflowState.EnableWorkflow(ctx, name, wfPath)
142+ }
143+ if err != nil {
144+ return ports.WorkflowInfo{}, err
145+ }
146+ state := "enabled"
147+ if disable {
148+ state = "disabled"
149+ }
150+ ctxLog(ctx).Info().Str("repo", name.String()).Str("workflow", wfPath).Str("principal", p.Label()).Msg("ci workflow " + state)
151+ return ports.WorkflowInfo{Path: wfPath, Disabled: disable}, nil
152+}
@@ -0,0 +1,152 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "fmt"
8+ "slices"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// Disabled workflows (`ermite workflow disable|enable|list`, the equivalent of
15+// `gh workflow disable`). The state is per repository and per workflow file
16+// path, kept in the store apart from Git: it holds for every branch and every
17+// commit, and the file does not have to exist, so it can be set on an empty
18+// repository before the first push.
19+//
20+// A disabled workflow starts no run for any event. The workflow files are read
21+// through workflowFiles for push, pull_request, pull_request_target, create,
22+// delete, schedule and workflow_run, and that one place leaves the disabled
23+// ones out; workflow_dispatch and the firing of a cached schedule check the
24+// state themselves (the cache holds every workflow). Whatever was checked
25+// before, the store refuses to create a run for a disabled workflow in the very
26+// transaction that creates it (CIStore.CreateRun, domain.ErrWorkflowDisabled),
27+// so a disable that lands while a push, a dispatch or a schedule is being
28+// planned cannot be overtaken. A run already started is not touched, and the events that
29+// happened while a workflow was disabled are not replayed when it is enabled.
30+
31+// workflowDisabled reports whether the path is disabled; nothing is when no
32+// store is configured.
33+func (c *CI) workflowDisabled(ctx context.Context, repo domain.RepoName, wfPath string) (bool, error) {
34+ off, err := c.disabledPaths(ctx, repo)
35+ if err != nil {
36+ return false, err
37+ }
38+ return slices.Contains(off, wfPath), nil
39+}
40+
41+// disabledPaths are the disabled paths of a repository, sorted.
42+func (c *CI) disabledPaths(ctx context.Context, repo domain.RepoName) ([]string, error) {
43+ if c.cfg.WorkflowState == nil {
44+ return nil, nil
45+ }
46+ off, err := c.cfg.WorkflowState.DisabledWorkflows(ctx, repo)
47+ if err != nil {
48+ return nil, fmt.Errorf("read the disabled workflows: %w", err)
49+ }
50+ return off, nil
51+}
52+
53+// ListWorkflows returns the workflow files at the tip of the default branch and
54+// the disabled ones whatever the branch holds, ordered by path. Reading the
55+// repository is enough.
56+func (c *CI) ListWorkflows(ctx context.Context, p domain.Principal, name domain.RepoName) ([]ports.WorkflowInfo, error) {
57+ if _, err := c.readableRepo(ctx, p, name); err != nil {
58+ return nil, err
59+ }
60+ off, err := c.disabledPaths(ctx, name)
61+ if err != nil {
62+ return nil, err
63+ }
64+ byPath := map[string]*ports.WorkflowInfo{}
65+ if tip, ok := c.defaultTip(ctx, name); ok {
66+ files, err := c.listWorkflowFiles(ctx, name, tip)
67+ if err != nil {
68+ return nil, err
69+ }
70+ for _, f := range files {
71+ info := &ports.WorkflowInfo{Path: f.path}
72+ if f.problem == "" {
73+ if wf, perrs := parseWorkflow(f.path, f.content); len(perrs) == 0 && wf != nil {
74+ info.Name = workflowName(wf, f.path)
75+ }
76+ }
77+ byPath[f.path] = info
78+ }
79+ }
80+ for _, path := range off {
81+ if byPath[path] == nil {
82+ byPath[path] = &ports.WorkflowInfo{Path: path}
83+ }
84+ byPath[path].Disabled = true
85+ }
86+ out := make([]ports.WorkflowInfo, 0, len(byPath))
87+ for _, info := range byPath {
88+ out = append(out, *info)
89+ }
90+ slices.SortFunc(out, func(a, b ports.WorkflowInfo) int {
91+ switch {
92+ case a.Path < b.Path:
93+ return -1
94+ case a.Path > b.Path:
95+ return 1
96+ }
97+ return 0
98+ })
99+ return out, nil
100+}
101+
102+// DisabledWorkflows returns the disabled paths of a repository, sorted, without
103+// reading any file.
104+func (c *CI) DisabledWorkflows(ctx context.Context, p domain.Principal, name domain.RepoName) ([]string, error) {
105+ if _, err := c.readableRepo(ctx, p, name); err != nil {
106+ return nil, err
107+ }
108+ return c.disabledPaths(ctx, name)
109+}
110+
111+// DisableWorkflow stops a workflow file from starting runs. The file is named by
112+// its path or its file name and does not have to exist. It needs repo:admin on
113+// the repository (adminAccess), and is idempotent.
114+func (c *CI) DisableWorkflow(ctx context.Context, p domain.Principal, name domain.RepoName, file string) (ports.WorkflowInfo, error) {
115+ return c.setWorkflowState(ctx, p, name, file, true)
116+}
117+
118+// EnableWorkflow undoes DisableWorkflow. Same access; idempotent.
119+func (c *CI) EnableWorkflow(ctx context.Context, p domain.Principal, name domain.RepoName, file string) (ports.WorkflowInfo, error) {
120+ return c.setWorkflowState(ctx, p, name, file, false)
121+}
122+
123+func (c *CI) setWorkflowState(ctx context.Context, p domain.Principal, name domain.RepoName, file string, disable bool) (ports.WorkflowInfo, error) {
124+ repo, err := lookupRepo(ctx, c.cfg.Meta, name)
125+ if err != nil {
126+ return ports.WorkflowInfo{}, err
127+ }
128+ if err := adminAccess(p, repo); err != nil {
129+ return ports.WorkflowInfo{}, err
130+ }
131+ wfPath, err := domain.ParseWorkflowPath(file)
132+ if err != nil {
133+ return ports.WorkflowInfo{}, err
134+ }
135+ if c.cfg.WorkflowState == nil {
136+ return ports.WorkflowInfo{}, fmt.Errorf("%w: this server keeps no workflow state", domain.ErrPreconditionFailed)
137+ }
138+ if disable {
139+ err = c.cfg.WorkflowState.DisableWorkflow(ctx, name, wfPath, c.now())
140+ } else {
141+ err = c.cfg.WorkflowState.EnableWorkflow(ctx, name, wfPath)
142+ }
143+ if err != nil {
144+ return ports.WorkflowInfo{}, err
145+ }
146+ state := "enabled"
147+ if disable {
148+ state = "disabled"
149+ }
150+ ctxLog(ctx).Info().Str("repo", name.String()).Str("workflow", wfPath).Str("principal", p.Label()).Msg("ci workflow " + state)
151+ return ports.WorkflowInfo{Path: wfPath, Disabled: disable}, nil
152+}
M proto/ermite/storage/ci.proto +7 -0
@@ -466,3 +466,10 @@ message CIConcurrencyMark {
466466 // that a binary that predates it still cancels the entry.
467467 bool queue_full = 4;
468468 }
469+
470+// CIWorkflowDisabled is stored under "ciwfoff/<repo>/<workflow path>" (the path
471+// is ".github/workflows/<file>"): the workflow is disabled and starts no run.
472+// The file does not have to exist. It goes with the repository.
473+message CIWorkflowDisabled {
474+ google.protobuf.Timestamp disabled_at = 1;
475+}
@@ -466,3 +466,10 @@ message CIConcurrencyMark {
466 // that a binary that predates it still cancels the entry.466 // that a binary that predates it still cancels the entry.
467 bool queue_full = 4;467 bool queue_full = 4;
468 }468 }
469+
470+// CIWorkflowDisabled is stored under "ciwfoff/<repo>/<workflow path>" (the path
471+// is ".github/workflows/<file>"): the workflow is disabled and starts no run.
472+// The file does not have to exist. It goes with the repository.
473+message CIWorkflowDisabled {
474+ google.protobuf.Timestamp disabled_at = 1;
475+}
M skills/ermite/CLI.md +11 -0
@@ -62,6 +62,17 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
6262
6363 Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`.
6464
65+## Workflows
66+
67+| Command | Scope | Notes |
68+|---|---|---|
69+| `workflow list <repo> [--json]` | `repo:read` for a private repository | the files of `.github/workflows` on the default branch with their state, and the disabled files that branch lacks |
70+| `workflow disable <repo> <file> [--json]` | `repo:admin` | like `gh workflow disable`: no event (push, pull_request, pull_request_target, schedule, workflow_run, create, delete, workflow_dispatch) starts a run of it; runs already started go on; `<file>` is `release.yml` or `.github/workflows/release.yml` and need not exist, so it works on an empty repository before the first push; doing it twice is not an error |
71+| `workflow enable <repo> <file> [--json]` | `repo:admin` | undoes `disable`; missed events are not replayed |
72+| `workflow run <file> --repo R [--ref REF] [-f name=value]` | `ci:write` | starts a `workflow_dispatch` workflow; refused while the workflow is disabled |
73+
74+Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `name:` on the default branch, empty when the file is absent there), `state` (`enabled` or `disabled`). A token limited to branch prefixes cannot disable or enable.
75+
6576 ## Events
6677
6778 `ermite watch [repo] [--json]` runs until interrupted, reconnects and resumes after the last event. With `--json` it prints one object per line with `id`, `time`, `type` and type-specific fields.
@@ -62,6 +62,17 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
62 62
63 Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`.63 Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `target_branch`, `state`, `author` (token label), `created_at`, `updated_at`, `head_id`, `merged_commit_id`, `merge_mode`. `mr show --json` adds `commits`, `diff_base_id`, `stats`, `files` (with `hunks` under `--diff`: `kind`, `old_number`, `new_number`, `text`), `comments`, `last_read_id`, `last_read_lost`.
64 64
65+## Workflows
66+
67+| Command | Scope | Notes |
68+|---|---|---|
69+| `workflow list <repo> [--json]` | `repo:read` for a private repository | the files of `.github/workflows` on the default branch with their state, and the disabled files that branch lacks |
70+| `workflow disable <repo> <file> [--json]` | `repo:admin` | like `gh workflow disable`: no event (push, pull_request, pull_request_target, schedule, workflow_run, create, delete, workflow_dispatch) starts a run of it; runs already started go on; `<file>` is `release.yml` or `.github/workflows/release.yml` and need not exist, so it works on an empty repository before the first push; doing it twice is not an error |
71+| `workflow enable <repo> <file> [--json]` | `repo:admin` | undoes `disable`; missed events are not replayed |
72+| `workflow run <file> --repo R [--ref REF] [-f name=value]` | `ci:write` | starts a `workflow_dispatch` workflow; refused while the workflow is disabled |
73+
74+Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `name:` on the default branch, empty when the file is absent there), `state` (`enabled` or `disabled`). A token limited to branch prefixes cannot disable or enable.
75+
65 ## Events76 ## Events
66 77
67 `ermite watch [repo] [--json]` runs until interrupted, reconnects and resumes after the last event. With `--json` it prints one object per line with `id`, `time`, `type` and type-specific fields.78 `ermite watch [repo] [--json]` runs until interrupted, reconnects and resumes after the last event. With `--json` it prints one object per line with `id`, `time`, `type` and type-specific fields.

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 2

Tags 42

↑↓ moveenter openesc close