detached f9ee4b9

feat(ci): limit run writes and runner management of branch-limited tokens

Cancel and re-run follow the branch prefixes of the token, on the ref of the run. A branch-limited token never manages runners.
commit
f9ee4b91659bf99d21a26b138d57b211ea738de0
author
brice · · 2026-10-12 20:09 UTC
parent
0651976
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +179 -5
M README.md +1 -0
@@ -100,6 +100,7 @@ For an AI agent that should propose changes, not publish them: `--branch-prefix
100100 - It can push, force-push and delete `refs/heads/agent/claude/…`. Any other ref (`main`, other branches, tags, notes, anything outside `refs/heads/`) refuses the whole push, and the message names the ref.
101101 - It cannot create a repository (push to a new name, or `repo create`), delete one, or change its settings (default branch, visibility, description, merge modes, failing-checks rule): those decide what happens to branches outside its prefixes. It never has the rights of the owner (it cannot open web sessions even with every scope), and can only create tokens whose prefixes lie within its own.
102102 - It opens, comments on and closes merge requests as usual; with `mr:merge` it can merge only into a branch under its prefixes, and delete only a source branch under them. You review and merge the agent's request.
103+- CI: it reads runs, jobs, logs and checks as any token that can read the repository. With `ci:write` it can cancel and re-run only the runs of branches under its prefixes, never those of `main`, other branches or tags. It never manages runners (`runner list|remove`, registration), even with `runner:register`: a runner runs every branch of every repository.
103104 - `ermite token list` shows a `branches` column (`all` when unlimited); `--json` has `branch_prefixes`.
104105 - This build stores tokens in storage schema 2. Once it has started on your data, an older ermite refuses to start on it (it would read limited tokens as unrestricted): to go back, restore the backup taken before the upgrade.
105106
@@ -100,6 +100,7 @@ For an AI agent that should propose changes, not publish them: `--branch-prefix
100 - It can push, force-push and delete `refs/heads/agent/claude/…`. Any other ref (`main`, other branches, tags, notes, anything outside `refs/heads/`) refuses the whole push, and the message names the ref.100 - It can push, force-push and delete `refs/heads/agent/claude/…`. Any other ref (`main`, other branches, tags, notes, anything outside `refs/heads/`) refuses the whole push, and the message names the ref.
101 - It cannot create a repository (push to a new name, or `repo create`), delete one, or change its settings (default branch, visibility, description, merge modes, failing-checks rule): those decide what happens to branches outside its prefixes. It never has the rights of the owner (it cannot open web sessions even with every scope), and can only create tokens whose prefixes lie within its own.101 - It cannot create a repository (push to a new name, or `repo create`), delete one, or change its settings (default branch, visibility, description, merge modes, failing-checks rule): those decide what happens to branches outside its prefixes. It never has the rights of the owner (it cannot open web sessions even with every scope), and can only create tokens whose prefixes lie within its own.
102 - It opens, comments on and closes merge requests as usual; with `mr:merge` it can merge only into a branch under its prefixes, and delete only a source branch under them. You review and merge the agent's request.102 - It opens, comments on and closes merge requests as usual; with `mr:merge` it can merge only into a branch under its prefixes, and delete only a source branch under them. You review and merge the agent's request.
103+- CI: it reads runs, jobs, logs and checks as any token that can read the repository. With `ci:write` it can cancel and re-run only the runs of branches under its prefixes, never those of `main`, other branches or tags. It never manages runners (`runner list|remove`, registration), even with `runner:register`: a runner runs every branch of every repository.
103 - `ermite token list` shows a `branches` column (`all` when unlimited); `--json` has `branch_prefixes`.104 - `ermite token list` shows a `branches` column (`all` when unlimited); `--json` has `branch_prefixes`.
104 - This build stores tokens in storage schema 2. Once it has started on your data, an older ermite refuses to start on it (it would read limited tokens as unrestricted): to go back, restore the backup taken before the upgrade.105 - This build stores tokens in storage schema 2. Once it has started on your data, an older ermite refuses to start on it (it would read limited tokens as unrestricted): to go back, restore the backup taken before the upgrade.
105 106
A internal/app/ci_branch_token_test.go +140 -0
@@ -0,0 +1,140 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "testing"
7+
8+ "connectrpc.com/connect"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+const everyBranchWorkflow = `name: all
17+on:
18+ push:
19+ branches: ['**']
20+ tags: ['v*']
21+jobs:
22+ j:
23+ runs-on: ubuntu-latest
24+ steps:
25+ - run: echo hi
26+`
27+
28+// A token limited to branch prefixes (ADR 0027) and CI. Reading runs, jobs and
29+// logs is not a write and stays open to whoever can read the repository.
30+// Cancelling and re-running a run is a write on the ref of the run (it stops or
31+// repeats the workflow of that ref), so it follows the prefixes: allowed on a
32+// branch under one, refused on any other branch and on a tag. Runners serve
33+// every repository and branch, so a limited token never manages them, even with
34+// runner:register.
35+func TestCIBranchLimitedToken(t *testing.T) {
36+ c := newCIHarness(t, nil)
37+ runner := c.registerRunner("alpha", "ubuntu-latest")
38+ wf := map[string]string{".github/workflows/all.yml": everyBranchWorkflow}
39+
40+ c.push("main", wf, "")
41+ c.waitFor("the run of main", func() bool { return len(c.runs()) == 1 })
42+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of main")
43+ c.push("agent/claude/x", nil, "")
44+ c.waitFor("the run of the agent branch", func() bool { return len(c.runs()) == 2 })
45+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of the agent branch")
46+ c.mustGit(c.wt, "tag", "v1")
47+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
48+ c.waitFor("the run of the tag", func() bool { return len(c.runs()) == 3 })
49+ c.waitFor("main is done", func() bool { return c.runs()[2].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
50+
51+ var mainRun, agentRun, tagRun *pb.Run
52+ for _, r := range c.runs() {
53+ switch r.GetRef() {
54+ case "refs/heads/main":
55+ mainRun = r
56+ case "refs/heads/agent/claude/x":
57+ agentRun = r
58+ case "refs/tags/v1":
59+ tagRun = r
60+ }
61+ }
62+ require.NotNil(t, mainRun)
63+ require.NotNil(t, agentRun)
64+ require.NotNil(t, tagRun)
65+
66+ all := scopes(domain.ScopeRepoRead, domain.ScopeCIWrite, domain.ScopeRunnerRegister)
67+ secret, _ := c.branchToken(all, []string{"agent/claude/"}, "proj")
68+ limited := c.api(secret)
69+ // The same token without the limit, to prove the refusals come from it.
70+ unlimited := c.api(c.token(all, "proj"))
71+ cancel := func(api *clients, r *pb.Run) error {
72+ _, err := api.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: r.GetId()}))
73+ return err
74+ }
75+ rerun := func(api *clients, r *pb.Run) error {
76+ _, err := api.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: r.GetId()}))
77+ return err
78+ }
79+
80+ t.Run("reads", func(t *testing.T) {
81+ list, err := limited.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"}))
82+ require.NoError(t, err)
83+ assert.Len(t, list.Msg.GetRuns(), 3, "every run is readable, not only those of its prefixes")
84+ got, err := limited.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: mainRun.GetId()}))
85+ require.NoError(t, err)
86+ require.NotEmpty(t, got.Msg.GetJobs())
87+ log, err := limited.run.GetJobLog(c.ctx, connect.NewRequest(&pb.GetJobLogRequest{Repo: "proj", JobId: got.Msg.GetJobs()[0].GetId()}))
88+ require.NoError(t, err)
89+ var text string
90+ for log.Receive() {
91+ for _, l := range log.Msg().GetLines() {
92+ text += l.GetText()
93+ }
94+ }
95+ require.NoError(t, log.Err())
96+ assert.Contains(t, text, "log of main")
97+ _, err = limited.run.ListChecks(c.ctx, connect.NewRequest(&pb.ListChecksRequest{Repo: "proj", Sha: mainRun.GetSha()}))
98+ require.NoError(t, err)
99+ })
100+
101+ t.Run("writes outside the prefixes are refused", func(t *testing.T) {
102+ for name, r := range map[string]*pb.Run{"main": mainRun, "a tag": tagRun} {
103+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(cancel(limited, r)), "cancel "+name)
104+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(rerun(limited, r)), "rerun "+name)
105+ }
106+ // Still there: the refused rerun started no new attempt.
107+ assert.EqualValues(t, 1, c.getRun(mainRun.GetId()).GetRun().GetAttempt())
108+ // Without the limit the same token is allowed (rerun) or reaches the
109+ // state rule of the run (cancel of a completed run).
110+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(cancel(unlimited, mainRun)))
111+ require.NoError(t, rerun(unlimited, mainRun))
112+ })
113+
114+ t.Run("writes under the prefixes are allowed", func(t *testing.T) {
115+ require.NoError(t, rerun(limited, agentRun))
116+ assert.EqualValues(t, 2, c.getRun(agentRun.GetId()).GetRun().GetAttempt())
117+ require.NoError(t, cancel(limited, agentRun))
118+ })
119+
120+ t.Run("runners are out of reach", func(t *testing.T) {
121+ // Not restricted to a repository, so only the branch limit can refuse it.
122+ secret, _ := c.branchToken(scopes(domain.ScopeRunnerRegister), []string{"agent/claude/"})
123+ limited := c.api(secret)
124+ _, err := limited.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{
125+ Name: "evil", Version: "v1", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux", Replace: true,
126+ }))
127+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "register")
128+ _, err = limited.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{}))
129+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "list")
130+ _, err = limited.run.RemoveRunner(c.ctx, connect.NewRequest(&pb.RemoveRunnerRequest{RunnerId: runner.id}))
131+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "remove")
132+
133+ // The same scopes without the limit manage runners (not restricted to a
134+ // repository, which the runner rules already demand).
135+ open := c.api(c.token(scopes(domain.ScopeRunnerRegister)))
136+ list, err := open.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{}))
137+ require.NoError(t, err)
138+ assert.Len(t, list.Msg.GetRunners(), 1, "the runner is still registered")
139+ })
140+}
@@ -0,0 +1,140 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "testing"
7+
8+ "connectrpc.com/connect"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+const everyBranchWorkflow = `name: all
17+on:
18+ push:
19+ branches: ['**']
20+ tags: ['v*']
21+jobs:
22+ j:
23+ runs-on: ubuntu-latest
24+ steps:
25+ - run: echo hi
26+`
27+
28+// A token limited to branch prefixes (ADR 0027) and CI. Reading runs, jobs and
29+// logs is not a write and stays open to whoever can read the repository.
30+// Cancelling and re-running a run is a write on the ref of the run (it stops or
31+// repeats the workflow of that ref), so it follows the prefixes: allowed on a
32+// branch under one, refused on any other branch and on a tag. Runners serve
33+// every repository and branch, so a limited token never manages them, even with
34+// runner:register.
35+func TestCIBranchLimitedToken(t *testing.T) {
36+ c := newCIHarness(t, nil)
37+ runner := c.registerRunner("alpha", "ubuntu-latest")
38+ wf := map[string]string{".github/workflows/all.yml": everyBranchWorkflow}
39+
40+ c.push("main", wf, "")
41+ c.waitFor("the run of main", func() bool { return len(c.runs()) == 1 })
42+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of main")
43+ c.push("agent/claude/x", nil, "")
44+ c.waitFor("the run of the agent branch", func() bool { return len(c.runs()) == 2 })
45+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of the agent branch")
46+ c.mustGit(c.wt, "tag", "v1")
47+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
48+ c.waitFor("the run of the tag", func() bool { return len(c.runs()) == 3 })
49+ c.waitFor("main is done", func() bool { return c.runs()[2].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
50+
51+ var mainRun, agentRun, tagRun *pb.Run
52+ for _, r := range c.runs() {
53+ switch r.GetRef() {
54+ case "refs/heads/main":
55+ mainRun = r
56+ case "refs/heads/agent/claude/x":
57+ agentRun = r
58+ case "refs/tags/v1":
59+ tagRun = r
60+ }
61+ }
62+ require.NotNil(t, mainRun)
63+ require.NotNil(t, agentRun)
64+ require.NotNil(t, tagRun)
65+
66+ all := scopes(domain.ScopeRepoRead, domain.ScopeCIWrite, domain.ScopeRunnerRegister)
67+ secret, _ := c.branchToken(all, []string{"agent/claude/"}, "proj")
68+ limited := c.api(secret)
69+ // The same token without the limit, to prove the refusals come from it.
70+ unlimited := c.api(c.token(all, "proj"))
71+ cancel := func(api *clients, r *pb.Run) error {
72+ _, err := api.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: r.GetId()}))
73+ return err
74+ }
75+ rerun := func(api *clients, r *pb.Run) error {
76+ _, err := api.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: r.GetId()}))
77+ return err
78+ }
79+
80+ t.Run("reads", func(t *testing.T) {
81+ list, err := limited.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"}))
82+ require.NoError(t, err)
83+ assert.Len(t, list.Msg.GetRuns(), 3, "every run is readable, not only those of its prefixes")
84+ got, err := limited.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: mainRun.GetId()}))
85+ require.NoError(t, err)
86+ require.NotEmpty(t, got.Msg.GetJobs())
87+ log, err := limited.run.GetJobLog(c.ctx, connect.NewRequest(&pb.GetJobLogRequest{Repo: "proj", JobId: got.Msg.GetJobs()[0].GetId()}))
88+ require.NoError(t, err)
89+ var text string
90+ for log.Receive() {
91+ for _, l := range log.Msg().GetLines() {
92+ text += l.GetText()
93+ }
94+ }
95+ require.NoError(t, log.Err())
96+ assert.Contains(t, text, "log of main")
97+ _, err = limited.run.ListChecks(c.ctx, connect.NewRequest(&pb.ListChecksRequest{Repo: "proj", Sha: mainRun.GetSha()}))
98+ require.NoError(t, err)
99+ })
100+
101+ t.Run("writes outside the prefixes are refused", func(t *testing.T) {
102+ for name, r := range map[string]*pb.Run{"main": mainRun, "a tag": tagRun} {
103+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(cancel(limited, r)), "cancel "+name)
104+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(rerun(limited, r)), "rerun "+name)
105+ }
106+ // Still there: the refused rerun started no new attempt.
107+ assert.EqualValues(t, 1, c.getRun(mainRun.GetId()).GetRun().GetAttempt())
108+ // Without the limit the same token is allowed (rerun) or reaches the
109+ // state rule of the run (cancel of a completed run).
110+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(cancel(unlimited, mainRun)))
111+ require.NoError(t, rerun(unlimited, mainRun))
112+ })
113+
114+ t.Run("writes under the prefixes are allowed", func(t *testing.T) {
115+ require.NoError(t, rerun(limited, agentRun))
116+ assert.EqualValues(t, 2, c.getRun(agentRun.GetId()).GetRun().GetAttempt())
117+ require.NoError(t, cancel(limited, agentRun))
118+ })
119+
120+ t.Run("runners are out of reach", func(t *testing.T) {
121+ // Not restricted to a repository, so only the branch limit can refuse it.
122+ secret, _ := c.branchToken(scopes(domain.ScopeRunnerRegister), []string{"agent/claude/"})
123+ limited := c.api(secret)
124+ _, err := limited.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{
125+ Name: "evil", Version: "v1", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux", Replace: true,
126+ }))
127+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "register")
128+ _, err = limited.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{}))
129+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "list")
130+ _, err = limited.run.RemoveRunner(c.ctx, connect.NewRequest(&pb.RemoveRunnerRequest{RunnerId: runner.id}))
131+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "remove")
132+
133+ // The same scopes without the limit manage runners (not restricted to a
134+ // repository, which the runner rules already demand).
135+ open := c.api(c.token(scopes(domain.ScopeRunnerRegister)))
136+ list, err := open.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{}))
137+ require.NoError(t, err)
138+ assert.Len(t, list.Msg.GetRunners(), 1, "the runner is still registered")
139+ })
140+}
M internal/usecase/ci.go +26 -1
@@ -462,9 +462,32 @@ func (c *CI) writableRepo(ctx context.Context, p domain.Principal, name domain.R
462462 return repo, nil
463463 }
464464
465+// runWriteAccess is the branch rule of the write actions on a run, cancel and
466+// re-run, which come after the ci:write check. A run is tied to the ref that
467+// made it, and re-running it executes the workflow of that ref again while
468+// cancelling it stops the checks of that ref. So, like a push, a merge or a
469+// branch delete, these actions fall under the branch prefixes of a limited
470+// token (ADR 0027): it may act only on the runs of branches under its prefixes,
471+// and never on a run of a tag or of any other ref. Without prefixes, every run.
472+//
473+// Reading runs, jobs, logs and checks is not a write: it needs only the access
474+// to read the repository, as for the code, whatever the prefixes. The caller
475+// has seen the run by then, so the refusal is forbidden, not not found.
476+func runWriteAccess(p domain.Principal, run domain.CIRun) error {
477+ if !p.AllowsRef(run.Ref) {
478+ return fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, run.Ref)
479+ }
480+ return nil
481+}
482+
465483 // canManageRunners is the access rule of runners: runner:register on a token
466484 // that is not restricted to repositories (a runner serves every repository),
467-// or root.
485+// or root. A token limited to branch prefixes never manages runners, whatever
486+// scopes it holds: a runner runs the workflows of every branch of every
487+// repository, with their code and secrets, so registering or removing one
488+// reaches far beyond the prefixes (a replaced runner could also be a way to
489+// take over the jobs of main). Like repository administration and owner
490+// rights, it is out of reach of a limited token.
468491 func canManageRunners(p domain.Principal) error {
469492 switch {
470493 case p.IsAnonymous():
@@ -473,6 +496,8 @@ func canManageRunners(p domain.Principal) error {
473496 return fmt.Errorf("%w: this needs the %s scope", domain.ErrForbidden, domain.ScopeRunnerRegister)
474497 case len(p.Repos()) > 0:
475498 return fmt.Errorf("%w: a runner serves every repository: the token must not be restricted to some", domain.ErrForbidden)
499+ case p.IsBranchLimited():
500+ return fmt.Errorf("%w: a token limited to branch prefixes may not manage runners", domain.ErrForbidden)
476501 }
477502 return nil
478503 }
@@ -462,9 +462,32 @@ func (c *CI) writableRepo(ctx context.Context, p domain.Principal, name domain.R
462 return repo, nil462 return repo, nil
463 }463 }
464 464
465+// runWriteAccess is the branch rule of the write actions on a run, cancel and
466+// re-run, which come after the ci:write check. A run is tied to the ref that
467+// made it, and re-running it executes the workflow of that ref again while
468+// cancelling it stops the checks of that ref. So, like a push, a merge or a
469+// branch delete, these actions fall under the branch prefixes of a limited
470+// token (ADR 0027): it may act only on the runs of branches under its prefixes,
471+// and never on a run of a tag or of any other ref. Without prefixes, every run.
472+//
473+// Reading runs, jobs, logs and checks is not a write: it needs only the access
474+// to read the repository, as for the code, whatever the prefixes. The caller
475+// has seen the run by then, so the refusal is forbidden, not not found.
476+func runWriteAccess(p domain.Principal, run domain.CIRun) error {
477+ if !p.AllowsRef(run.Ref) {
478+ return fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, run.Ref)
479+ }
480+ return nil
481+}
482+
465 // canManageRunners is the access rule of runners: runner:register on a token483 // canManageRunners is the access rule of runners: runner:register on a token
466 // that is not restricted to repositories (a runner serves every repository),484 // that is not restricted to repositories (a runner serves every repository),
467-// or root.485+// or root. A token limited to branch prefixes never manages runners, whatever
486+// scopes it holds: a runner runs the workflows of every branch of every
487+// repository, with their code and secrets, so registering or removing one
488+// reaches far beyond the prefixes (a replaced runner could also be a way to
489+// take over the jobs of main). Like repository administration and owner
490+// rights, it is out of reach of a limited token.
468 func canManageRunners(p domain.Principal) error {491 func canManageRunners(p domain.Principal) error {
469 switch {492 switch {
470 case p.IsAnonymous():493 case p.IsAnonymous():
@@ -473,6 +496,8 @@ func canManageRunners(p domain.Principal) error {
473 return fmt.Errorf("%w: this needs the %s scope", domain.ErrForbidden, domain.ScopeRunnerRegister)496 return fmt.Errorf("%w: this needs the %s scope", domain.ErrForbidden, domain.ScopeRunnerRegister)
474 case len(p.Repos()) > 0:497 case len(p.Repos()) > 0:
475 return fmt.Errorf("%w: a runner serves every repository: the token must not be restricted to some", domain.ErrForbidden)498 return fmt.Errorf("%w: a runner serves every repository: the token must not be restricted to some", domain.ErrForbidden)
499+ case p.IsBranchLimited():
500+ return fmt.Errorf("%w: a token limited to branch prefixes may not manage runners", domain.ErrForbidden)
476 }501 }
477 return nil502 return nil
478 }503 }
M internal/usecase/ci_runs.go +12 -4
@@ -318,11 +318,15 @@ func (c *CI) CancelRun(ctx context.Context, p domain.Principal, name domain.Repo
318318 if _, err := c.writableRepo(ctx, p, name); err != nil {
319319 return domain.CIRun{}, err
320320 }
321- if _, err := c.runOf(ctx, name, runID); err != nil {
321+ run, err := c.runOf(ctx, name, runID)
322+ if err != nil {
323+ return domain.CIRun{}, err
324+ }
325+ if err := runWriteAccess(p, run); err != nil {
322326 return domain.CIRun{}, err
323327 }
324328 var out domain.CIRun
325- err := c.withRun(ctx, runID, 0, func(rs *runState) error {
329+ err = c.withRun(ctx, runID, 0, func(rs *runState) error {
326330 if rs.run.Status == domain.CICompleted {
327331 return fmt.Errorf("%w: run %d has completed", domain.ErrPreconditionFailed, runID)
328332 }
@@ -351,7 +355,11 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN
351355 if err != nil {
352356 return domain.CIRun{}, err
353357 }
354- if _, err := c.runOf(ctx, name, runID); err != nil {
358+ run, err := c.runOf(ctx, name, runID)
359+ if err != nil {
360+ return domain.CIRun{}, err
361+ }
362+ if err := runWriteAccess(p, run); err != nil {
355363 return domain.CIRun{}, err
356364 }
357365 err = c.withRun(ctx, runID, 0, func(rs *runState) error {
@@ -416,7 +424,7 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN
416424 if err := c.advanceRun(ctx, runID); err != nil {
417425 return domain.CIRun{}, err
418426 }
419- run, err := c.runOf(ctx, name, runID)
427+ run, err = c.runOf(ctx, name, runID)
420428 if err != nil {
421429 return domain.CIRun{}, err
422430 }
@@ -318,11 +318,15 @@ func (c *CI) CancelRun(ctx context.Context, p domain.Principal, name domain.Repo
318 if _, err := c.writableRepo(ctx, p, name); err != nil {318 if _, err := c.writableRepo(ctx, p, name); err != nil {
319 return domain.CIRun{}, err319 return domain.CIRun{}, err
320 }320 }
321- if _, err := c.runOf(ctx, name, runID); err != nil {321+ run, err := c.runOf(ctx, name, runID)
322+ if err != nil {
323+ return domain.CIRun{}, err
324+ }
325+ if err := runWriteAccess(p, run); err != nil {
322 return domain.CIRun{}, err326 return domain.CIRun{}, err
323 }327 }
324 var out domain.CIRun328 var out domain.CIRun
325- err := c.withRun(ctx, runID, 0, func(rs *runState) error {329+ err = c.withRun(ctx, runID, 0, func(rs *runState) error {
326 if rs.run.Status == domain.CICompleted {330 if rs.run.Status == domain.CICompleted {
327 return fmt.Errorf("%w: run %d has completed", domain.ErrPreconditionFailed, runID)331 return fmt.Errorf("%w: run %d has completed", domain.ErrPreconditionFailed, runID)
328 }332 }
@@ -351,7 +355,11 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN
351 if err != nil {355 if err != nil {
352 return domain.CIRun{}, err356 return domain.CIRun{}, err
353 }357 }
354- if _, err := c.runOf(ctx, name, runID); err != nil {358+ run, err := c.runOf(ctx, name, runID)
359+ if err != nil {
360+ return domain.CIRun{}, err
361+ }
362+ if err := runWriteAccess(p, run); err != nil {
355 return domain.CIRun{}, err363 return domain.CIRun{}, err
356 }364 }
357 err = c.withRun(ctx, runID, 0, func(rs *runState) error {365 err = c.withRun(ctx, runID, 0, func(rs *runState) error {
@@ -416,7 +424,7 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN
416 if err := c.advanceRun(ctx, runID); err != nil {424 if err := c.advanceRun(ctx, runID); err != nil {
417 return domain.CIRun{}, err425 return domain.CIRun{}, err
418 }426 }
419- run, err := c.runOf(ctx, name, runID)427+ run, err = c.runOf(ctx, name, runID)
420 if err != nil {428 if err != nil {
421 return domain.CIRun{}, err429 return domain.CIRun{}, err
422 }430 }

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 45

↑↓ moveenter openesc close