feat(ci): plan steps with uses, job containers, services and step timeouts
CI-2 runs what CI-1 refused with "not supported yet". Reusable workflows, environment and concurrency are still refused (CI-3).
- commit
- bfdf4d9b9d048789f077bef70c2072b23d5011a5
- author
- brice · · 2026-10-13 10:34 UTC
- parent
- cd77819
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +9 -20
M README.md +1 -1
| @@ -116,7 +116,7 @@ ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a | |||
| 116 | 116 | ||
| 117 | 117 | Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails. | |
| 118 | 118 | ||
| 119 | - | `ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. A workflow that uses a feature the runner does not support yet (`uses:` steps, `container:`, `services:`, `environment:`, `concurrency`, `continue-on-error`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`. | |
| 119 | + | `ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. A workflow that uses a feature ermite does not support yet (reusable workflows, `environment:`, `concurrency`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`. | |
| 120 | 120 | ||
| 121 | 121 | ## Web sign-in | |
| 122 | 122 | ||
| @@ -116,7 +116,7 @@ ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a | |||||
| 116 | 116 | ||||
| 117 | Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails. | 117 | Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails. | ||
| 118 | 118 | ||||
| 119 | - | `ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. A workflow that uses a feature the runner does not support yet (`uses:` steps, `container:`, `services:`, `environment:`, `concurrency`, `continue-on-error`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`. | 119 | + | `ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. A workflow that uses a feature ermite does not support yet (reusable workflows, `environment:`, `concurrency`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`. |
| 120 | 120 | ||||
| 121 | ## Web sign-in | 121 | ## Web sign-in | ||
| 122 | 122 | ||||
M internal/app/ci_flow_test.go +3 -2
| @@ -324,13 +324,14 @@ func TestCIUnsupportedFeatureFailsTheJob(t *testing.T) { | |||
| 324 | 324 | jobs: | |
| 325 | 325 | j: | |
| 326 | 326 | runs-on: ubuntu-latest | |
| 327 | + | environment: production | |
| 327 | 328 | steps: | |
| 328 | - | - uses: actions/checkout@v4 | |
| 329 | + | - run: echo | |
| 329 | 330 | `}, "") | |
| 330 | 331 | c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | |
| 331 | 332 | j := c.getRun(c.runs()[0].GetId()).GetJobs()[0] | |
| 332 | 333 | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion()) | |
| 333 | - | assert.Equal(t, "not supported yet: uses: actions/checkout@v4", j.GetError()) | |
| 334 | + | assert.Equal(t, "not supported yet: environment", j.GetError()) | |
| 334 | 335 | } | |
| 335 | 336 | ||
| 336 | 337 | // A job for a label no runner has is "waiting for a runner" and fails after the | |
| @@ -324,13 +324,14 @@ func TestCIUnsupportedFeatureFailsTheJob(t *testing.T) { | |||||
| 324 | jobs: | 324 | jobs: | ||
| 325 | j: | 325 | j: | ||
| 326 | runs-on: ubuntu-latest | 326 | runs-on: ubuntu-latest | ||
| 327 | + | environment: production | |||
| 327 | steps: | 328 | steps: | ||
| 328 | - | - uses: actions/checkout@v4 | 329 | + | - run: echo |
| 329 | `}, "") | 330 | `}, "") | ||
| 330 | c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | 331 | c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | ||
| 331 | j := c.getRun(c.runs()[0].GetId()).GetJobs()[0] | 332 | j := c.getRun(c.runs()[0].GetId()).GetJobs()[0] | ||
| 332 | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion()) | 333 | assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion()) | ||
| 333 | - | assert.Equal(t, "not supported yet: uses: actions/checkout@v4", j.GetError()) | 334 | + | assert.Equal(t, "not supported yet: environment", j.GetError()) |
| 334 | } | 335 | } | ||
| 335 | 336 | ||||
| 336 | // A job for a label no runner has is "waiting for a runner" and fails after the | 337 | // A job for a label no runner has is "waiting for a runner" and fails after the | ||
M internal/usecase/ci_plan.go +5 -17
| @@ -52,32 +52,19 @@ func (w *wfCache) get(runID int64, path, yaml string) (*workflow.Workflow, error | |||
| 52 | 52 | return wf, nil | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | - | // unsupported names the feature of a job that CI-1 does not run, or "". Such | |
| 56 | - | // a job fails with "not supported yet: <feature>" instead of running wrong. | |
| 57 | - | // Relax it as the runner learns the features (ADR 0025, CI-2 and CI-3). | |
| 55 | + | // unsupported names the feature of a job that ermite does not run yet, or "". | |
| 56 | + | // Such a job fails with "not supported yet: <feature>" instead of running | |
| 57 | + | // wrong. CI-2 runs steps with uses:, job container:, services: and step | |
| 58 | + | // timeout-minutes (ADR 0025); what stays is CI-3. | |
| 58 | 59 | func unsupported(j *workflow.Job) string { | |
| 59 | 60 | switch { | |
| 60 | 61 | case j.IsReusable(): | |
| 61 | 62 | return "reusable workflows (uses at the job level)" | |
| 62 | - | case j.Container != nil: | |
| 63 | - | return "container" | |
| 64 | - | case j.Services != nil: | |
| 65 | - | return "services" | |
| 66 | 63 | case j.Environment != nil: | |
| 67 | 64 | return "environment" | |
| 68 | 65 | case j.Concurrency != nil: | |
| 69 | 66 | return "concurrency" | |
| 70 | 67 | } | |
| 71 | - | for _, s := range j.Steps { | |
| 72 | - | switch { | |
| 73 | - | case s.Uses != nil: | |
| 74 | - | return "uses: " + s.Uses.Value | |
| 75 | - | case s.TimeoutMinutes != nil: | |
| 76 | - | // The runner refuses it too (executor.notSupported): it has no | |
| 77 | - | // step timeout yet. | |
| 78 | - | return "timeout-minutes on a step" | |
| 79 | - | } | |
| 80 | - | } | |
| 81 | 68 | return "" | |
| 82 | 69 | } | |
| 83 | 70 | ||
| @@ -253,6 +240,7 @@ func (rs *runState) end(j *domain.CIJob, conclusion domain.CIConclusion, msg str | |||
| 253 | 240 | if j.HasToken() { | |
| 254 | 241 | j.TokenValidUntil = now.Add(domain.CITokenGrace) | |
| 255 | 242 | } | |
| 243 | + | rs.revokeTokens(j) | |
| 256 | 244 | rs.touch(j) | |
| 257 | 245 | rs.c.mu.Lock() | |
| 258 | 246 | delete(rs.c.heartbeats, jobRef(*j)) | |
| @@ -52,32 +52,19 @@ func (w *wfCache) get(runID int64, path, yaml string) (*workflow.Workflow, error | |||||
| 52 | return wf, nil | 52 | return wf, nil | ||
| 53 | } | 53 | } | ||
| 54 | 54 | ||||
| 55 | - | // unsupported names the feature of a job that CI-1 does not run, or "". Such | 55 | + | // unsupported names the feature of a job that ermite does not run yet, or "". |
| 56 | - | // a job fails with "not supported yet: <feature>" instead of running wrong. | 56 | + | // Such a job fails with "not supported yet: <feature>" instead of running |
| 57 | - | // Relax it as the runner learns the features (ADR 0025, CI-2 and CI-3). | 57 | + | // wrong. CI-2 runs steps with uses:, job container:, services: and step |
| 58 | + | // timeout-minutes (ADR 0025); what stays is CI-3. | |||
| 58 | func unsupported(j *workflow.Job) string { | 59 | func unsupported(j *workflow.Job) string { | ||
| 59 | switch { | 60 | switch { | ||
| 60 | case j.IsReusable(): | 61 | case j.IsReusable(): | ||
| 61 | return "reusable workflows (uses at the job level)" | 62 | return "reusable workflows (uses at the job level)" | ||
| 62 | - | case j.Container != nil: | |||
| 63 | - | return "container" | |||
| 64 | - | case j.Services != nil: | |||
| 65 | - | return "services" | |||
| 66 | case j.Environment != nil: | 63 | case j.Environment != nil: | ||
| 67 | return "environment" | 64 | return "environment" | ||
| 68 | case j.Concurrency != nil: | 65 | case j.Concurrency != nil: | ||
| 69 | return "concurrency" | 66 | return "concurrency" | ||
| 70 | } | 67 | } | ||
| 71 | - | for _, s := range j.Steps { | |||
| 72 | - | switch { | |||
| 73 | - | case s.Uses != nil: | |||
| 74 | - | return "uses: " + s.Uses.Value | |||
| 75 | - | case s.TimeoutMinutes != nil: | |||
| 76 | - | // The runner refuses it too (executor.notSupported): it has no | |||
| 77 | - | // step timeout yet. | |||
| 78 | - | return "timeout-minutes on a step" | |||
| 79 | - | } | |||
| 80 | - | } | |||
| 81 | return "" | 68 | return "" | ||
| 82 | } | 69 | } | ||
| 83 | 70 | ||||
| @@ -253,6 +240,7 @@ func (rs *runState) end(j *domain.CIJob, conclusion domain.CIConclusion, msg str | |||||
| 253 | if j.HasToken() { | 240 | if j.HasToken() { | ||
| 254 | j.TokenValidUntil = now.Add(domain.CITokenGrace) | 241 | j.TokenValidUntil = now.Add(domain.CITokenGrace) | ||
| 255 | } | 242 | } | ||
| 243 | + | rs.revokeTokens(j) | |||
| 256 | rs.touch(j) | 244 | rs.touch(j) | ||
| 257 | rs.c.mu.Lock() | 245 | rs.c.mu.Lock() | ||
| 258 | delete(rs.c.heartbeats, jobRef(*j)) | 246 | delete(rs.c.heartbeats, jobRef(*j)) | ||