Compare commits

...

6 Commits

Author SHA1 Message Date
default
e0ede17bbe fix: show devcontainer delete menu for failed devcontainers
The three-dot menu (containing Delete) was gated on showDevcontainerControls
which requires both subAgent AND devcontainer.container to be truthy. For failed
devcontainers, the container and/or agent are typically missing, so the menu
never rendered.

The fix decouples the more-actions menu from showDevcontainerControls. The
delete API only needs parentAgent.id and devcontainer.id (always available),
so the menu now renders whenever devcontainer.status is not 'deleting'.

Fixes #23754
2026-04-07 20:49:48 +00:00
Garrett Delfosse
48bc215f20 chore: tag RCs on main, cut release branch only for releases (#24001)
RC tags are now created directly on `main`. The `release/X.Y` branch is
only cut when the actual release is ready. This eliminates the need to
cherry-pick hundreds of commits from main onto the release branch
between the first RC and the release.

## Workflow

```
main:  ──●──●──●──●──●──●──●──●──●──
              ↑           ↑     ↑
           rc.0        rc.1    cut release/2.34, tag v2.34.0
                                     \
                               release/2.34:  ──●── v2.34.1 (patch)
```

1. **RC:** On `main`, run `./scripts/release.sh`. The tool detects main
(or a detached HEAD reachable from main), prompts for the commit SHA to
tag, suggests the next RC version, and tags it.
2. **Release:** When the RC is blessed, create `release/X.Y` from `main`
(or the specific RC commit). Switch to that branch and run
`./scripts/release.sh`, which suggests `vX.Y.0`.
3. **Patch:** Cherry-pick fixes onto `release/X.Y` and run
`./scripts/release.sh` from that branch.

## Changes

### `scripts/releaser/release.go`
- Two modes based on branch:
- **`main` (or detached HEAD from main)** — RC tagging. Prompts for the
commit SHA to tag (defaults to HEAD). Always checks out the target
commit so the flow operates in detached HEAD. Suggests the next RC based
on existing RC tags.
- **`release/X.Y`** — Release/patch mode. Suggests `vX.Y.0` if the
latest tag is an RC, or the next patch otherwise.
- Detached HEAD support: if `git branch --show-current` is empty, checks
whether HEAD is an ancestor of `origin/main` and enters RC mode
automatically.
- Commit selection prompt in RC mode: shows current commit, lets the
user confirm or provide a different SHA.
- Warns if you try to tag a non-RC on main, or an RC on a release
branch.
- Skips open-PR check and branch sync check in RC mode (not useful on
main).

### `scripts/releaser/main.go`
- Updated help text.

### `.github/workflows/release.yaml`
- RC tags (`*-rc.*`): skip the release-branch validation (they live on
main).
- Non-RC tags: still require the corresponding `release/X.Y` branch.

### `docs/about/contributing/CONTRIBUTING.md`
- Rewrote the Releases section with the new workflow, release types
table, and ASCII diagram.
- Replaced the old "Creating a release" / "Creating a release (via
workflow dispatch)" subsections.

<details><summary>Decision log</summary>

### Why this approach?

Previously, cutting a release branch early for an RC meant
cherry-picking all of main's progress onto that branch before the actual
release — often hundreds of commits. This approach avoids that entirely:
RCs are just tagged snapshots of main, and the release branch only
exists once you need it for stabilization and backports.

### Files NOT changed

- **`scripts/release/publish.sh`** — `--rc` flag controls GitHub
prerelease marking (tag-level, not branch-level). `target_commitish`
already defaults to `main` when the tag isn't on a release branch.
- **`scripts/release/tag_version.sh`** — No RC-specific branch logic.
- **`scripts/releaser/version.go`** — Version parsing/comparison
unchanged.
- **`docs/install/releases/index.md`** — Public-facing docs describe RC
as a release channel with no branch-level detail.

</details>

> Generated by Coder Agents
2026-04-07 15:21:22 -04:00
Jon Ayers
08bd9e672a fix: resolve Test_batcherFlush/RetriesOnTransientFailure flake (#24112)
fixes https://github.com/coder/internal/issues/1452
2026-04-07 13:46:26 -05:00
Kayla はな
c5f1a2fccf feat: make service accounts a Premium feature (#24020) 2026-04-07 12:25:32 -06:00
Jake Howell
655d647d40 fix: resolve style not passing in <LogLine /> (#24111)
This pull-request resolves an regression where the spread was overriding
the required styles from the `react-window` virtualised rows. This was
causing the scroll to act a little crazy.
2026-04-07 17:54:16 +00:00
Kyle Carberry
f3f0a2c553 fix(enterprise/coderd/x/chatd): harden TestSubscribeRelayEstablishedMidStream against CI flakes (#24108)
Fixes coder/internal#1455

Three changes to eliminate the timing-sensitive flake in
`TestSubscribeRelayEstablishedMidStream`:

1. **Reduce `PendingChatAcquireInterval` from `time.Hour` to
`time.Second`.**
   The primary trigger is still `signalWake()` from `SendMessage`, but a
   short fallback poll ensures the worker picks up the pending chat
   even under heavy CI goroutine scheduling contention.

2. **Increase context timeout from `WaitLong` (25s) to `WaitSuperLong`
(60s).**
   The worker pipeline (model resolution, message loading, LLM call)
   involves multiple DB round-trips that can be slow when PostgreSQL
   is shared with many parallel test packages.

3. **Add a status-polling loop while waiting for the streaming
request.**
   If the worker errors out during chat processing, the test now
   fails immediately with the error status and message instead of
   silently timing out.

> Generated by Coder Agents
2026-04-07 13:41:33 -04:00
25 changed files with 662 additions and 298 deletions

View File

@@ -121,22 +121,22 @@ jobs:
fi
# Derive the release branch from the version tag.
# Standard: 2.10.2 -> release/2.10
# RC: 2.32.0-rc.0 -> release/2.32-rc.0
# Non-RC releases must be on a release/X.Y branch.
# RC tags are allowed on any branch (typically main).
version="$(./scripts/version.sh)"
# Strip any pre-release suffix first (e.g. 2.32.0-rc.0 -> 2.32.0)
base_version="${version%%-*}"
# Then strip patch to get major.minor (e.g. 2.32.0 -> 2.32)
release_branch="release/${base_version%.*}"
if [[ "$version" == *-rc.* ]]; then
# Extract major.minor and rc suffix from e.g. 2.32.0-rc.0
base_version="${version%%-rc.*}" # 2.32.0
major_minor="${base_version%.*}" # 2.32
rc_suffix="${version##*-rc.}" # 0
release_branch="release/${major_minor}-rc.${rc_suffix}"
echo "RC release detected — skipping release branch check (RC tags are cut from main)."
else
release_branch=release/${version%.*}
fi
branch_contains_tag=$(git branch --remotes --contains "${GITHUB_REF}" --list "*/${release_branch}" --format='%(refname)')
if [[ -z "${branch_contains_tag}" ]]; then
echo "Ref tag must exist in a branch named ${release_branch} when creating a release, did you use scripts/release.sh?"
exit 1
branch_contains_tag=$(git branch --remotes --contains "${GITHUB_REF}" --list "*/${release_branch}" --format='%(refname)')
if [[ -z "${branch_contains_tag}" ]]; then
echo "Ref tag must exist in a branch named ${release_branch} when creating a non-RC release, did you use scripts/release.sh?"
exit 1
fi
fi
if [[ -z "${CODER_RELEASE_NOTES}" ]]; then

View File

@@ -134,6 +134,7 @@ func TestUserCreate(t *testing.T) {
{
name: "ServiceAccount",
args: []string{"--service-account", "-u", "dean"},
err: "Premium feature",
},
{
name: "ServiceAccountLoginType",

View File

@@ -123,6 +123,10 @@ func UsersPagination(
require.Contains(t, gotUsers[0].Name, "after")
}
type UsersFilterOptions struct {
CreateServiceAccounts bool
}
// UsersFilter creates a set of users to run various filters against for
// testing. It can be used to test filtering both users and group members.
func UsersFilter(
@@ -130,11 +134,16 @@ func UsersFilter(
t *testing.T,
client *codersdk.Client,
db database.Store,
options *UsersFilterOptions,
setup func(users []codersdk.User),
fetch func(ctx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser,
) {
t.Helper()
if options == nil {
options = &UsersFilterOptions{}
}
firstUser, err := client.User(setupCtx, codersdk.Me)
require.NoError(t, err, "fetch me")
@@ -211,11 +220,13 @@ func UsersFilter(
}
// Add some service accounts.
for range 3 {
_, user := CreateAnotherUserMutators(t, client, orgID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
r.ServiceAccount = true
})
users = append(users, user)
if options.CreateServiceAccounts {
for range 3 {
_, user := CreateAnotherUserMutators(t, client, orgID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
r.ServiceAccount = true
})
users = append(users, user)
}
}
hashedPassword, err := userpassword.Hash("SomeStrongPassword!")

View File

@@ -148,7 +148,7 @@ func TestGetOrgMembersFilter(t *testing.T) {
setupCtx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
coderdtest.UsersFilter(setupCtx, t, client, api.Database, nil, func(testCtx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser {
coderdtest.UsersFilter(setupCtx, t, client, api.Database, nil, nil, func(testCtx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser {
res, err := client.OrganizationMembersPaginated(testCtx, first.OrganizationID, req)
require.NoError(t, err)
reduced := make([]codersdk.ReducedUser, len(res.Members))

View File

@@ -475,6 +475,14 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) {
}
req.UserLoginType = codersdk.LoginTypeNone
// Service accounts are a Premium feature.
if !api.Entitlements.Enabled(codersdk.FeatureServiceAccounts) {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: fmt.Sprintf("%s is a Premium feature. Contact sales!", codersdk.FeatureServiceAccounts.Humanize()),
})
return
}
} else if req.UserLoginType == "" {
// Default to password auth
req.UserLoginType = codersdk.LoginTypePassword

View File

@@ -979,7 +979,7 @@ func TestPostUsers(t *testing.T) {
require.Equal(t, found.LoginType, codersdk.LoginTypeOIDC)
})
t.Run("ServiceAccount/OK", func(t *testing.T) {
t.Run("ServiceAccount/Unlicensed", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
@@ -987,98 +987,16 @@ func TestPostUsers(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
OrganizationIDs: []uuid.UUID{first.OrganizationID},
Username: "service-acct-ok",
UserLoginType: codersdk.LoginTypeNone,
ServiceAccount: true,
})
require.NoError(t, err)
require.Equal(t, codersdk.LoginTypeNone, user.LoginType)
require.Empty(t, user.Email)
require.Equal(t, "service-acct-ok", user.Username)
require.Equal(t, codersdk.UserStatusDormant, user.Status)
})
t.Run("ServiceAccount/WithEmail", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
OrganizationIDs: []uuid.UUID{first.OrganizationID},
Username: "service-acct-email",
Email: "should-not-have@email.com",
ServiceAccount: true,
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Email cannot be set for service accounts")
})
t.Run("ServiceAccount/WithPassword", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
OrganizationIDs: []uuid.UUID{first.OrganizationID},
Username: "service-acct-password",
Password: "ShouldNotHavePassword123!",
ServiceAccount: true,
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Password cannot be set for service accounts")
})
t.Run("ServiceAccount/WithInvalidLoginType", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
OrganizationIDs: []uuid.UUID{first.OrganizationID},
Username: "service-acct-login-type",
UserLoginType: codersdk.LoginTypePassword,
ServiceAccount: true,
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Service accounts must use login type 'none'")
})
t.Run("ServiceAccount/DefaultLoginType", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
OrganizationIDs: []uuid.UUID{first.OrganizationID},
Username: "service-acct-default-login",
ServiceAccount: true,
})
require.NoError(t, err)
found, err := client.User(ctx, user.ID.String())
require.NoError(t, err)
require.Equal(t, codersdk.LoginTypeNone, found.LoginType)
require.Empty(t, found.Email)
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Premium feature")
})
t.Run("NonServiceAccount/WithoutEmail", func(t *testing.T) {
@@ -1098,32 +1016,6 @@ func TestPostUsers(t *testing.T) {
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
})
t.Run("ServiceAccount/MultipleWithoutEmail", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
user1, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
OrganizationIDs: []uuid.UUID{first.OrganizationID},
Username: "service-acct-multi-1",
ServiceAccount: true,
})
require.NoError(t, err)
require.Empty(t, user1.Email)
user2, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
OrganizationIDs: []uuid.UUID{first.OrganizationID},
Username: "service-acct-multi-2",
ServiceAccount: true,
})
require.NoError(t, err)
require.Empty(t, user2.Email)
require.NotEqual(t, user1.ID, user2.ID)
})
}
func TestNotifyCreatedUser(t *testing.T) {
@@ -1832,7 +1724,7 @@ func TestGetUsersFilter(t *testing.T) {
setupCtx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
coderdtest.UsersFilter(setupCtx, t, client, api.Database, nil, func(testCtx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser {
coderdtest.UsersFilter(setupCtx, t, client, api.Database, nil, nil, func(testCtx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser {
res, err := client.Users(testCtx, req)
require.NoError(t, err)
reduced := make([]codersdk.ReducedUser, len(res.Users))

View File

@@ -196,6 +196,7 @@ const (
FeatureWorkspaceExternalAgent FeatureName = "workspace_external_agent"
FeatureAIBridge FeatureName = "aibridge"
FeatureBoundary FeatureName = "boundary"
FeatureServiceAccounts FeatureName = "service_accounts"
FeatureAIGovernanceUserLimit FeatureName = "ai_governance_user_limit"
)
@@ -227,6 +228,7 @@ var (
FeatureWorkspaceExternalAgent,
FeatureAIBridge,
FeatureBoundary,
FeatureServiceAccounts,
FeatureAIGovernanceUserLimit,
}
@@ -275,6 +277,7 @@ func (n FeatureName) AlwaysEnable() bool {
FeatureWorkspacePrebuilds: true,
FeatureWorkspaceExternalAgent: true,
FeatureBoundary: true,
FeatureServiceAccounts: true,
}[n]
}
@@ -282,7 +285,7 @@ func (n FeatureName) AlwaysEnable() bool {
func (n FeatureName) Enterprise() bool {
switch n {
// Add all features that should be excluded in the Enterprise feature set.
case FeatureMultipleOrganizations, FeatureCustomRoles:
case FeatureMultipleOrganizations, FeatureCustomRoles, FeatureServiceAccounts:
return false
default:
return true

View File

@@ -211,33 +211,53 @@ Coder releases are initiated via
[`./scripts/release.sh`](https://github.com/coder/coder/blob/main/scripts/release.sh)
and automated via GitHub Actions. Specifically, the
[`release.yaml`](https://github.com/coder/coder/blob/main/.github/workflows/release.yaml)
workflow. They are created based on the current
[`main`](https://github.com/coder/coder/tree/main) branch.
workflow.
The release notes for a release are automatically generated from commit titles
and metadata from PRs that are merged into `main`.
Release notes are automatically generated from commit titles and PR metadata.
### Creating a release
### Release types
The creation of a release is initiated via
[`./scripts/release.sh`](https://github.com/coder/coder/blob/main/scripts/release.sh).
This script will show a preview of the release that will be created, and if you
choose to continue, create and push the tag which will trigger the creation of
the release via GitHub Actions.
| Type | Tag | Branch | Purpose |
|------------------------|---------------|---------------|-----------------------------------------|
| RC (release candidate) | `vX.Y.0-rc.W` | `main` | Ad-hoc pre-release for customer testing |
| Release | `vX.Y.0` | `release/X.Y` | First release of a minor version |
| Patch | `vX.Y.Z` | `release/X.Y` | Bug fixes and security patches |
See `./scripts/release.sh --help` for more information.
### Workflow
RC tags are created directly on `main`. The `release/X.Y` branch is only cut
when the release is ready. This avoids cherry-picking main's progress onto
a release branch between the first RC and the release.
```text
main: ──●──●──●──●──●──●──●──●──●──
↑ ↑ ↑
rc.0 rc.1 cut release/2.34, tag v2.34.0
\
release/2.34: ──●── v2.34.1 (patch)
```
1. **RC:** On `main`, run `./scripts/release.sh`. The tool suggests the next
RC version and tags it on `main`.
2. **Release:** When the RC is blessed, create `release/X.Y` from `main` (or
the specific RC commit). Switch to that branch and run
`./scripts/release.sh`, which suggests `vX.Y.0`.
3. **Patch:** Cherry-pick fixes onto `release/X.Y` and run
`./scripts/release.sh` from that branch.
The release tool warns if you try to tag a non-RC on `main` or an RC on a
release branch.
### Creating a release (via workflow dispatch)
Typically the workflow dispatch is only used to test (dry-run) a release,
meaning no actual release will take place. The workflow can be dispatched
manually from
[Actions: Release](https://github.com/coder/coder/actions/workflows/release.yaml).
Simply press "Run workflow" and choose dry-run.
If the
[`release.yaml`](https://github.com/coder/coder/actions/workflows/release.yaml)
workflow fails after the tag has been pushed, retry it from the GitHub Actions
UI: press "Run workflow", set "Use workflow from" to the tag (e.g.
`Tag: v2.34.0`), select the correct release channel, and do **not** select
dry-run.
If a release has failed after the tag has been created and pushed, it can be
retried by again, pressing "Run workflow", changing "Use workflow from" from
"Branch: main" to "Tag: vX.X.X" and not selecting dry-run.
To test the workflow without publishing, select dry-run.
### Commit messages

View File

@@ -1,31 +1,38 @@
# Headless Authentication
Headless user accounts that cannot use the web UI to log in to Coder. This is
useful for creating accounts for automated systems, such as CI/CD pipelines or
for users who only consume Coder via another client/API.
> [!NOTE]
> Creating service accounts requires a [Premium license](https://coder.com/pricing).
You must have the User Admin role or above to create headless users.
Service accounts are headless user accounts that cannot use the web UI to log in
to Coder. This is useful for creating accounts for automated systems, such as
CI/CD pipelines or for users who only consume Coder via another client/API. Service accounts do not have passwords or associated email addresses.
## Create a headless user
You must have the User Admin role or above to create service accounts.
## Create a service account
<div class="tabs">
## CLI
Use the `--service-account` flag to create a dedicated service account:
```sh
coder users create \
--email="coder-bot@coder.com" \
--username="coder-bot" \
--login-type="none" \
--service-account
```
## UI
Navigate to the `Users` > `Create user` in the topbar
Navigate to **Deployment** > **Users** > **Create user**, then select
**Service account** as the login type.
![Create a user via the UI](../../images/admin/users/headless-user.png)
</div>
## Authenticate as a service account
To make API or CLI requests on behalf of the headless user, learn how to
[generate API tokens on behalf of a user](./sessions-tokens.md#generate-a-long-lived-api-token-on-behalf-of-another-user).

View File

@@ -495,7 +495,8 @@
{
"title": "Headless Authentication",
"description": "Create and manage headless service accounts for automated systems and API integrations",
"path": "./admin/users/headless-auth.md"
"path": "./admin/users/headless-auth.md",
"state": ["premium"]
},
{
"title": "Groups \u0026 Roles",

View File

@@ -468,9 +468,10 @@ func (b *DBBatcher) retryLoop() {
func (b *DBBatcher) retryBatch(params database.BatchUpsertConnectionLogsParams) {
count := len(params.ID)
for attempt := range maxRetries {
t := time.NewTimer(retryInterval)
t := b.clock.NewTimer(retryInterval, "connectionLogBatcher", "retryBackoff")
select {
case <-b.ctx.Done():
t.Stop()
b.shutdownBatch(params)
return
case <-t.C:

View File

@@ -355,15 +355,20 @@ func Test_batcherFlush(t *testing.T) {
store := dbmock.NewMockStore(ctrl)
clock := quartz.NewMock(t)
scheduledTrap := clock.Trap().TimerReset("connectionLogBatcher", "scheduledFlush")
defer scheduledTrap.Close()
// Trap the capacity flush (fires when batch reaches maxBatchSize).
capacityTrap := clock.Trap().TimerReset("connectionLogBatcher", "capacityFlush")
defer capacityTrap.Close()
b := NewDBBatcher(ctx, store, log, WithClock(clock), WithBatchSize(100))
// Trap the retry backoff timer created by retryBatch.
retryTrap := clock.Trap().NewTimer("connectionLogBatcher", "retryBackoff")
defer retryTrap.Close()
// Batch size of 1: consuming the item triggers an immediate
// capacity flush, avoiding the timer/itemCh select race.
b := NewDBBatcher(ctx, store, log, WithClock(clock), WithBatchSize(1))
evt := fakeConnectEvent(uuid.New(), "agent1", uuid.New())
// First call (synchronous in flush) fails, then the
// retry worker retries after the backoff and succeeds.
gomock.InOrder(
store.EXPECT().
BatchUpsertConnectionLogs(gomock.Any(), gomock.Any()).
@@ -380,14 +385,15 @@ func Test_batcherFlush(t *testing.T) {
require.NoError(t, b.Upsert(ctx, evt))
// Trigger a scheduled flush while the batcher is still
// running. The synchronous write fails and queues to
// retryCh. The retry worker picks it up after a real-
// time 1s delay and succeeds.
clock.Advance(defaultFlushInterval).MustWait(ctx)
scheduledTrap.MustWait(ctx).MustRelease(ctx)
// Item consumed → capacity flush fires → transient error →
// batch queued to retryCh → timer reset trapped.
capacityTrap.MustWait(ctx).MustRelease(ctx)
// Retry worker creates a timer — trap it, release, advance.
retryCall := retryTrap.MustWait(ctx)
retryCall.MustRelease(ctx)
clock.Advance(retryInterval).MustWait(ctx)
// Wait for the retry to complete (real-time 1s delay).
require.NoError(t, b.Close())
})
@@ -400,10 +406,10 @@ func Test_batcherFlush(t *testing.T) {
store := dbmock.NewMockStore(ctrl)
clock := quartz.NewMock(t)
scheduledTrap := clock.Trap().TimerReset("connectionLogBatcher", "scheduledFlush")
defer scheduledTrap.Close()
capacityTrap := clock.Trap().TimerReset("connectionLogBatcher", "capacityFlush")
defer capacityTrap.Close()
b := NewDBBatcher(ctx, store, log, WithClock(clock), WithBatchSize(100))
b := NewDBBatcher(ctx, store, log, WithClock(clock), WithBatchSize(1))
evt := fakeConnectEvent(uuid.New(), "agent1", uuid.New())
@@ -428,10 +434,9 @@ func Test_batcherFlush(t *testing.T) {
}).
AnyTimes()
// Send event and trigger flush — fails, queues.
// Send event — capacity flush triggers immediately.
require.NoError(t, b.Upsert(ctx, evt))
clock.Advance(defaultFlushInterval).MustWait(ctx)
scheduledTrap.MustWait(ctx).MustRelease(ctx)
capacityTrap.MustWait(ctx).MustRelease(ctx)
// Close triggers shutdown. The retry worker drains
// retryCh and writes the batch via writeBatch.

View File

@@ -1161,7 +1161,8 @@ func TestGetGroupMembersFilter(t *testing.T) {
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
codersdk.FeatureTemplateRBAC: 1,
codersdk.FeatureServiceAccounts: 1,
},
},
})
@@ -1191,7 +1192,8 @@ func TestGetGroupMembersFilter(t *testing.T) {
require.NoError(t, err)
return res.Users
}
coderdtest.UsersFilter(setupCtx, t, client, db, setup, fetch)
options := &coderdtest.UsersFilterOptions{CreateServiceAccounts: true}
coderdtest.UsersFilter(setupCtx, t, client, db, options, setup, fetch)
}
func TestGetGroupMembersPagination(t *testing.T) {

View File

@@ -614,4 +614,168 @@ func TestEnterprisePostUser(t *testing.T) {
require.Len(t, memberedOrgs, 2)
require.ElementsMatch(t, []uuid.UUID{second.ID, third.ID}, []uuid.UUID{memberedOrgs[0].ID, memberedOrgs[1].ID})
})
t.Run("ServiceAccount/OK", func(t *testing.T) {
t.Parallel()
client, first := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureServiceAccounts: 1,
},
},
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
//nolint:gocritic
user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
OrganizationIDs: []uuid.UUID{first.OrganizationID},
Username: "service-acct-ok",
UserLoginType: codersdk.LoginTypeNone,
ServiceAccount: true,
})
require.NoError(t, err)
require.Equal(t, codersdk.LoginTypeNone, user.LoginType)
require.Empty(t, user.Email)
require.Equal(t, "service-acct-ok", user.Username)
require.Equal(t, codersdk.UserStatusDormant, user.Status)
})
t.Run("ServiceAccount/WithEmail", func(t *testing.T) {
t.Parallel()
client, first := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureServiceAccounts: 1,
},
},
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
//nolint:gocritic
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
OrganizationIDs: []uuid.UUID{first.OrganizationID},
Username: "service-acct-email",
Email: "should-not-have@email.com",
ServiceAccount: true,
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Email cannot be set for service accounts")
})
t.Run("ServiceAccount/WithPassword", func(t *testing.T) {
t.Parallel()
client, first := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureServiceAccounts: 1,
},
},
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
//nolint:gocritic
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
OrganizationIDs: []uuid.UUID{first.OrganizationID},
Username: "service-acct-password",
Password: "ShouldNotHavePassword123!",
ServiceAccount: true,
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Password cannot be set for service accounts")
})
t.Run("ServiceAccount/WithInvalidLoginType", func(t *testing.T) {
t.Parallel()
client, first := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureServiceAccounts: 1,
},
},
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
//nolint:gocritic
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
OrganizationIDs: []uuid.UUID{first.OrganizationID},
Username: "service-acct-login-type",
UserLoginType: codersdk.LoginTypePassword,
ServiceAccount: true,
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Service accounts must use login type 'none'")
})
t.Run("ServiceAccount/DefaultLoginType", func(t *testing.T) {
t.Parallel()
client, first := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureServiceAccounts: 1,
},
},
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
//nolint:gocritic
user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
OrganizationIDs: []uuid.UUID{first.OrganizationID},
Username: "service-acct-default-login",
ServiceAccount: true,
})
require.NoError(t, err)
found, err := client.User(ctx, user.ID.String())
require.NoError(t, err)
require.Equal(t, codersdk.LoginTypeNone, found.LoginType)
require.Empty(t, found.Email)
})
t.Run("ServiceAccount/MultipleWithoutEmail", func(t *testing.T) {
t.Parallel()
client, first := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureServiceAccounts: 1,
},
},
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
//nolint:gocritic
user1, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
OrganizationIDs: []uuid.UUID{first.OrganizationID},
Username: "service-acct-multi-1",
ServiceAccount: true,
})
require.NoError(t, err)
require.Empty(t, user1.Email)
user2, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
OrganizationIDs: []uuid.UUID{first.OrganizationID},
Username: "service-acct-multi-2",
ServiceAccount: true,
})
require.NoError(t, err)
require.Empty(t, user2.Email)
require.NotEqual(t, user1.ID, user2.ID)
})
}

View File

@@ -231,7 +231,13 @@ func TestWorkspaceSharingDisabled(t *testing.T) {
t.Run("ACLEndpointsForbiddenServiceAccountsMode", func(t *testing.T) {
t.Parallel()
client, db, owner := coderdenttest.NewWithDatabase(t, nil)
client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureServiceAccounts: 1,
},
},
})
regularClient, regularUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
regularWS := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
@@ -444,7 +450,8 @@ func TestWorkspaceSharingDisabled(t *testing.T) {
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
codersdk.FeatureTemplateRBAC: 1,
codersdk.FeatureServiceAccounts: 1,
},
},
})

View File

@@ -1451,15 +1451,17 @@ func TestSubscribeRelayEstablishedMidStream(t *testing.T) {
)
})
// Worker with a 1-hour acquire interval; only processes when
// explicitly woken.
// Worker with a short fallback poll interval. The primary
// trigger is signalWake() from SendMessage, but under heavy
// CI load the wake goroutine may be delayed. A short poll
// ensures the worker always picks up the pending chat.
workerLogger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
worker := osschatd.New(osschatd.Config{
Logger: workerLogger,
Database: db,
ReplicaID: workerID,
Pubsub: ps,
PendingChatAcquireInterval: time.Hour,
PendingChatAcquireInterval: time.Second,
InFlightChatStaleAfter: testutil.WaitSuperLong,
})
t.Cleanup(func() {
@@ -1489,7 +1491,11 @@ func TestSubscribeRelayEstablishedMidStream(t *testing.T) {
return snapshot, relayEvents, cancel, nil
}, nil)
ctx := testutil.Context(t, testutil.WaitLong)
// Use WaitSuperLong so the test survives heavy CI contention.
// The worker pipeline (model resolution, message loading, LLM
// call) involves multiple DB round-trips that can be slow under
// load.
ctx := testutil.Context(t, testutil.WaitSuperLong)
user, model := seedChatDependencies(ctx, t, db)
setOpenAIProviderBaseURL(ctx, t, db, openAIURL)
@@ -1509,11 +1515,32 @@ func TestSubscribeRelayEstablishedMidStream(t *testing.T) {
})
require.NoError(t, err)
// Wait for the worker to reach the LLM (first streaming request).
select {
case <-firstChunkEmitted:
case <-ctx.Done():
t.Fatal("timed out waiting for worker to start streaming")
// Wait for the worker to reach the LLM (first streaming
// request). Also poll the chat status so we fail fast with a
// clear message if the worker errors out instead of timing
// out silently.
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
waitForStream:
for {
select {
case <-firstChunkEmitted:
break waitForStream
case <-ticker.C:
currentChat, dbErr := db.GetChatByID(ctx, chat.ID)
if dbErr == nil && currentChat.Status == database.ChatStatusError {
t.Fatalf("worker failed to process chat: status=%s last_error=%s",
currentChat.Status, currentChat.LastError.String)
}
case <-ctx.Done():
// Dump the final chat status for debugging.
currentChat, dbErr := db.GetChatByID(context.Background(), chat.ID)
if dbErr == nil {
t.Fatalf("timed out waiting for worker to start streaming (chat status=%s, last_error=%q)",
currentChat.Status, currentChat.LastError.String)
}
t.Fatal("timed out waiting for worker to start streaming")
}
}
// Wait for the subscriber to receive the running status, which

View File

@@ -23,7 +23,7 @@ func main() {
cmd := &serpent.Command{
Use: "releaser",
Short: "Interactive release tagging for coder/coder.",
Long: "Run this from a release branch (release/X.Y). The tool detects the branch, infers the next version, and walks you through tagging, pushing, and triggering the release workflow.",
Long: "Tag RCs from main, releases/patches from release/X.Y. The tool detects the branch, infers the next version, and walks you through tagging, pushing, and triggering the release workflow.",
Options: serpent.OptionSet{
{
Name: "dry-run",

View File

@@ -68,30 +68,110 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
return xerrors.Errorf("detecting branch: %w", err)
}
// Match release branches (release/X.Y). RCs are tagged
// from main, not from release branches.
// Two modes:
// 1. On "main" — for tagging release candidates (RCs).
// 2. On "release/X.Y" — for releases and patches.
// RCs are tagged directly on main to avoid the toil of
// cherry-picking hundreds of commits onto a release branch.
// The release/X.Y branch is only cut when the release is
// ready.
//
// Detached HEAD is common: the release manager checks out a
// specific commit on main before running the tool. We detect
// this by checking whether HEAD is an ancestor of origin/main.
branchRe := regexp.MustCompile(`^release/(\d+)\.(\d+)$`)
m := branchRe.FindStringSubmatch(currentBranch)
if m == nil {
warnf(w, "Current branch %q is not a release branch (release/X.Y).", currentBranch)
onMain := currentBranch == "main"
var branchMajor, branchMinor int
// Detached HEAD: currentBranch is empty. Check if HEAD is
// reachable from origin/main.
if currentBranch == "" {
if err := gitRun("merge-base", "--is-ancestor", "HEAD", "origin/main"); err == nil {
onMain = true
currentBranch = "main"
successf(w, "Detached HEAD is an ancestor of main — RC tagging mode.")
}
}
switch {
case onMain:
successf(w, "On main branch — RC tagging mode.")
case branchRe.MatchString(currentBranch):
m := branchRe.FindStringSubmatch(currentBranch)
branchMajor, _ = strconv.Atoi(m[1])
branchMinor, _ = strconv.Atoi(m[2])
successf(w, "Using release branch: %s", currentBranch)
default:
if currentBranch == "" {
warnf(w, "Detached HEAD is not reachable from origin/main.")
} else {
warnf(w, "Current branch %q is not 'main' or a release branch (release/X.Y).", currentBranch)
}
branchInput, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Enter the release branch to use (e.g. release/2.21)",
Text: "Enter the branch to use (e.g. main, release/2.21)",
Validate: func(s string) error {
if !branchRe.MatchString(s) {
return xerrors.New("must be in format release/X.Y (e.g. release/2.21)")
if s == "main" || branchRe.MatchString(s) {
return nil
}
return nil
return xerrors.New("must be 'main' or release/X.Y (e.g. release/2.21)")
},
})
if err != nil {
return err
}
currentBranch = branchInput
m = branchRe.FindStringSubmatch(currentBranch)
if currentBranch == "main" {
onMain = true
successf(w, "On main branch — RC tagging mode.")
} else {
m := branchRe.FindStringSubmatch(currentBranch)
branchMajor, _ = strconv.Atoi(m[1])
branchMinor, _ = strconv.Atoi(m[2])
successf(w, "Using release branch: %s", currentBranch)
}
}
// --- Commit selection (RC mode) ---
// RCs are always tagged at a specific commit. Show the current
// HEAD and let the user confirm or provide a different SHA.
// We always checkout the commit so the rest of the flow
// operates in detached HEAD at the exact commit being tagged.
if onMain {
headSHA, err := gitOutput("rev-parse", "HEAD")
if err != nil {
return xerrors.Errorf("resolving HEAD: %w", err)
}
headShort := headSHA[:12]
headTitle, _ := gitOutput("log", "-1", "--format=%s", "HEAD")
fmt.Fprintf(w, " Current commit: %s %s\n", headShort, headTitle)
fmt.Fprintln(w)
commitInput, err := cliui.Prompt(inv, cliui.PromptOptions{
Text: "Commit SHA to tag (press Enter to use current)",
Default: headShort,
})
if err != nil {
return err
}
commitInput = strings.TrimSpace(commitInput)
// Resolve the input to a full SHA.
targetSHA, err := gitOutput("rev-parse", commitInput)
if err != nil {
return xerrors.Errorf("resolving %q: %w", commitInput, err)
}
// Always checkout so we're in detached HEAD at the
// target commit for the rest of the flow.
if err := gitRun("checkout", "--quiet", targetSHA); err != nil {
return xerrors.Errorf("checking out %s: %w", commitInput, err)
}
if targetSHA != headSHA {
newTitle, _ := gitOutput("log", "-1", "--format=%s", "HEAD")
successf(w, "Checked out %s %s", targetSHA[:12], newTitle)
}
fmt.Fprintln(w)
}
branchMajor, _ := strconv.Atoi(m[1])
branchMinor, _ := strconv.Atoi(m[2])
successf(w, "Using release branch: %s", currentBranch)
// --- Fetch & sync check ---
infof(w, "Fetching latest from origin...")
@@ -99,20 +179,24 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
return xerrors.Errorf("fetching: %w", err)
}
localHead, err := gitOutput("rev-parse", "HEAD")
if err != nil {
return xerrors.Errorf("resolving HEAD: %w", err)
}
remoteHead, _ := gitOutput("rev-parse", "origin/"+currentBranch)
if remoteHead != "" && localHead != remoteHead {
warnf(w, "Your local branch is not up to date with origin/%s.", currentBranch)
fmt.Fprintf(w, " Local: %s\n", localHead[:12])
fmt.Fprintf(w, " Remote: %s\n", remoteHead[:12])
if err := confirmWithDefault(inv, "Continue anyway?", cliui.ConfirmNo); err != nil {
return err
// Skip the local-vs-remote sync check in RC mode because
// we always checkout a specific commit (detached HEAD).
if !onMain {
localHead, err := gitOutput("rev-parse", "HEAD")
if err != nil {
return xerrors.Errorf("resolving HEAD: %w", err)
}
remoteHead, _ := gitOutput("rev-parse", "origin/"+currentBranch)
if remoteHead != "" && localHead != remoteHead {
warnf(w, "Your local branch is not up to date with origin/%s.", currentBranch)
fmt.Fprintf(w, " Local: %s\n", localHead[:12])
fmt.Fprintf(w, " Remote: %s\n", remoteHead[:12])
if err := confirmWithDefault(inv, "Continue anyway?", cliui.ConfirmNo); err != nil {
return err
}
fmt.Fprintln(w)
}
fmt.Fprintln(w)
}
// --- Find previous version & suggest next ---
@@ -121,57 +205,130 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
return xerrors.Errorf("listing merged tags: %w", err)
}
// Find the latest tag matching this branch's major.minor.
// Without this filter, tags from newer branches (e.g. v2.31.0)
// that are reachable via merge history would be picked up
// incorrectly on older release branches (e.g. release/2.30).
var prevVersion *version
for _, t := range mergedTags {
if t.Major == branchMajor && t.Minor == branchMinor {
v := t
prevVersion = &v
break
}
}
// changelogBaseRef is the git ref used as the starting point
// for release notes generation. When a tag already exists in
// this minor series we use it directly. For the first release
// on a new minor no matching tag exists, so we compute the
// merge-base with the previous minor's release branch instead.
// This works even when that branch has no tags yet (it was
// just cut and pushed). As a last resort we fall back to the
// latest reachable tag from a previous minor.
var suggested version
var changelogBaseRef string
if prevVersion != nil {
changelogBaseRef = prevVersion.String()
} else {
prevReleaseBranch := fmt.Sprintf("release/%d.%d", branchMajor, branchMinor-1)
if err := gitRun("fetch", "--quiet", "origin", prevReleaseBranch); err != nil {
warnf(w, "Could not fetch %s: %v", prevReleaseBranch, err)
if onMain { //nolint:nestif // Sequential release flow with two distinct modes is inherently nested.
// On main, suggest the next RC. Find the latest RC tag
// across all tags, then suggest the next one. If no RC
// tags exist, suggest rc.0 for the next minor after the
// latest mainline release.
var latestRC *version
for _, t := range allTags {
if t.IsRC() {
v := t
latestRC = &v
break
}
}
if mb, mbErr := gitOutput("merge-base", "HEAD", "origin/"+prevReleaseBranch); mbErr == nil && mb != "" {
changelogBaseRef = mb
infof(w, "Using merge-base with %s as changelog base: %s", prevReleaseBranch, mb[:12])
} else {
// No previous release branch found; fall back to
// the latest reachable tag from a previous minor.
for _, t := range mergedTags {
if t.Major == branchMajor && t.Minor < branchMinor {
changelogBaseRef = t.String()
switch {
case latestRC != nil:
prevVersion = latestRC
infof(w, "Latest RC tag: %s", latestRC.String())
// Check if a final release already exists for this
// RC's minor series. If so, the series is complete
// and we should start the next minor's RC cycle.
seriesComplete := false
for _, t := range allTags {
if t.Major == latestRC.Major && t.Minor == latestRC.Minor && t.Pre == "" {
infof(w, "Final release %s already exists for this series, moving to next minor.", t.String())
seriesComplete = true
break
}
}
}
}
var suggested version
if prevVersion == nil {
infof(w, "No previous release tag found on this branch.")
suggested = version{Major: branchMajor, Minor: branchMinor, Patch: 0}
if seriesComplete {
suggested = version{
Major: latestRC.Major,
Minor: latestRC.Minor + 1,
Patch: 0,
Pre: "rc.0",
}
} else {
suggested = version{
Major: latestRC.Major,
Minor: latestRC.Minor,
Patch: latestRC.Patch,
Pre: fmt.Sprintf("rc.%d", latestRC.rcNumber()+1),
}
}
case latestMainline != nil:
infof(w, "No RC tags found. Latest mainline: %s", latestMainline.String())
suggested = version{
Major: latestMainline.Major,
Minor: latestMainline.Minor + 1,
Patch: 0,
Pre: "rc.0",
}
default:
infof(w, "No previous tags found.")
suggested = version{Major: 2, Minor: 0, Patch: 0, Pre: "rc.0"}
}
} else {
infof(w, "Previous release tag: %s", prevVersion.String())
suggested = version{Major: prevVersion.Major, Minor: prevVersion.Minor, Patch: prevVersion.Patch + 1}
// On a release branch, find the latest tag matching this
// branch's major.minor. Without this filter, tags from
// newer branches reachable via merge history would be
// picked up incorrectly.
for _, t := range mergedTags {
if t.Major == branchMajor && t.Minor == branchMinor {
v := t
prevVersion = &v
break
}
}
// changelogBaseRef is the git ref used as the starting
// point for release notes. When a tag exists in this
// minor series we use it directly. For the first release
// on a new minor no matching tag exists, so we compute
// the merge-base with the previous minor's release branch
// instead. This works even when that branch has no tags
// yet. As a last resort we fall back to the latest
// reachable tag from a previous minor.
if prevVersion == nil {
prevReleaseBranch := fmt.Sprintf("release/%d.%d", branchMajor, branchMinor-1)
if err := gitRun("fetch", "--quiet", "origin", prevReleaseBranch); err != nil {
warnf(w, "Could not fetch %s: %v", prevReleaseBranch, err)
}
if mb, mbErr := gitOutput("merge-base", "HEAD", "origin/"+prevReleaseBranch); mbErr == nil && mb != "" {
changelogBaseRef = mb
infof(w, "Using merge-base with %s as changelog base: %s", prevReleaseBranch, mb[:12])
} else {
// No previous release branch; fall back to the
// latest reachable tag from a previous minor.
for _, t := range mergedTags {
if t.Major == branchMajor && t.Minor < branchMinor {
changelogBaseRef = t.String()
break
}
}
}
}
if prevVersion == nil {
infof(w, "No previous release tag found on this branch.")
suggested = version{Major: branchMajor, Minor: branchMinor, Patch: 0}
} else {
infof(w, "Previous release tag: %s", prevVersion.String())
if prevVersion.IsRC() {
// Branch has only RC tags; suggest the
// release (same base, no pre-release suffix).
suggested = version{
Major: prevVersion.Major,
Minor: prevVersion.Minor,
Patch: prevVersion.Patch,
}
} else {
suggested = version{
Major: prevVersion.Major,
Minor: prevVersion.Minor,
Patch: prevVersion.Patch + 1,
}
}
}
}
fmt.Fprintln(w)
@@ -192,8 +349,13 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
}
newVersion, _ := parseVersion(versionInput)
// Warn if version doesn't match branch.
if newVersion.Major != branchMajor || newVersion.Minor != branchMinor {
// Validate version against branch context.
switch {
case onMain && !newVersion.IsRC():
return xerrors.Errorf("cannot tag a non-RC version (%s) on main; switch to a release/X.Y branch", newVersion)
case !onMain && newVersion.IsRC():
return xerrors.Errorf("cannot tag an RC (%s) on a release branch; switch to main", newVersion)
case !onMain && (newVersion.Major != branchMajor || newVersion.Minor != branchMinor):
warnf(w, "Version %s does not match branch %s (expected v%d.%d.X).",
newVersion, currentBranch, branchMajor, branchMinor)
if err := confirmWithDefault(inv, "Continue anyway?", cliui.ConfirmNo); err != nil {
@@ -220,34 +382,37 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
// --- Check open PRs ---
// This runs before breaking changes so any last-minute merges
// are caught by the subsequent checks.
infof(w, "Checking for open PRs against %s...", currentBranch)
var openPRs []ghPR
if ghAvailable {
openPRs, err = ghListOpenPRs(currentBranch)
if err != nil {
warnf(w, "Failed to check open PRs: %v", err)
// are caught by the subsequent checks. Skipped on main since
// there are always open PRs targeting main.
if !onMain {
infof(w, "Checking for open PRs against %s...", currentBranch)
var openPRs []ghPR
if ghAvailable {
openPRs, err = ghListOpenPRs(currentBranch)
if err != nil {
warnf(w, "Failed to check open PRs: %v", err)
}
} else {
infof(w, "Skipping (no gh CLI).")
}
} else {
infof(w, "Skipping (no gh CLI).")
}
if len(openPRs) > 0 {
fmt.Fprintln(w)
warnf(w, "There are open PRs targeting %s that may need merging first:", currentBranch)
fmt.Fprintln(w)
for _, pr := range openPRs {
fmt.Fprintf(w, " #%d %s (@%s)\n", pr.Number, pr.Title, pr.Author)
if len(openPRs) > 0 {
fmt.Fprintln(w)
warnf(w, "There are open PRs targeting %s that may need merging first:", currentBranch)
fmt.Fprintln(w)
for _, pr := range openPRs {
fmt.Fprintf(w, " #%d %s (@%s)\n", pr.Number, pr.Title, pr.Author)
}
fmt.Fprintln(w)
if err := confirmWithDefault(inv, "Continue without merging these?", cliui.ConfirmNo); err != nil {
return err
}
fmt.Fprintln(w)
} else {
successf(w, "No open PRs against %s.", currentBranch)
}
fmt.Fprintln(w)
if err := confirmWithDefault(inv, "Continue without merging these?", cliui.ConfirmNo); err != nil {
return err
}
fmt.Fprintln(w)
} else {
successf(w, "No open PRs against %s.", currentBranch)
}
fmt.Fprintln(w)
// --- Semver sanity checks ---
if prevVersion != nil { //nolint:nestif // Sequential release checks are inherently nested.
@@ -374,9 +539,14 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
// --- Generate release notes ---
infof(w, "Generating release notes...")
commitRange := "HEAD"
if changelogBaseRef != "" {
var commitRange string
switch {
case prevVersion != nil:
commitRange = prevVersion.String() + "..HEAD"
case changelogBaseRef != "":
commitRange = changelogBaseRef + "..HEAD"
default:
commitRange = "HEAD"
}
commits, err := commitLog(commitRange)
@@ -482,16 +652,20 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
}
if !hasContent {
prevStr := "the beginning of time"
if changelogBaseRef != "" {
prevStr = changelogBaseRef
if prevVersion != nil {
prevStr = prevVersion.String()
}
fmt.Fprintf(&notes, "\n_No changes since %s._\n", prevStr)
}
// Compare link.
if changelogBaseRef != "" {
compareBase := changelogBaseRef
if prevVersion != nil {
compareBase = prevVersion.String()
}
if compareBase != "" {
fmt.Fprintf(&notes, "\nCompare: [`%s...%s`](https://github.com/%s/%s/compare/%s...%s)\n",
changelogBaseRef, newVersion, owner, repo, changelogBaseRef, newVersion)
compareBase, newVersion, owner, repo, compareBase, newVersion)
}
// Container image.

View File

@@ -3511,6 +3511,7 @@ export type FeatureName =
| "multiple_external_auth"
| "multiple_organizations"
| "scim"
| "service_accounts"
| "task_batch_actions"
| "template_rbac"
| "user_limit"
@@ -3539,6 +3540,7 @@ export const FeatureNames: FeatureName[] = [
"multiple_external_auth",
"multiple_organizations",
"scim",
"service_accounts",
"task_batch_actions",
"template_rbac",
"user_limit",

View File

@@ -16,7 +16,12 @@ type LogLineProps = {
level: LogLevel;
} & HTMLAttributes<HTMLPreElement>;
export const LogLine: FC<LogLineProps> = ({ level, className, ...props }) => {
export const LogLine: FC<LogLineProps> = ({
level,
className,
style,
...props
}) => {
return (
<pre
{...props}
@@ -33,7 +38,10 @@ export const LogLine: FC<LogLineProps> = ({ level, className, ...props }) => {
className,
)}
style={{
padding: `0 var(--log-line-side-padding, ${DEFAULT_LOG_LINE_SIDE_PADDING}px)`,
...style,
padding:
style?.padding ??
`0 var(--log-line-side-padding, ${DEFAULT_LOG_LINE_SIDE_PADDING}px)`,
}}
/>
);

View File

@@ -53,9 +53,34 @@ export const HasError: Story = {
args: {
devcontainer: {
...MockWorkspaceAgentDevcontainer,
status: "error",
error: "unable to inject devcontainer with agent",
container: undefined,
agent: undefined,
},
subAgents: [],
},
};
export const HasErrorWithDelete: Story = {
args: {
devcontainer: {
...MockWorkspaceAgentDevcontainer,
status: "error",
error: "exit status 1",
container: undefined,
agent: undefined,
},
subAgents: [],
},
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const moreActionsButton = canvas.getByRole("button", {
name: "Dev Container actions",
});
await user.click(moreActionsButton);
},
};

View File

@@ -274,7 +274,7 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
/>
)}
{showDevcontainerControls && (
{devcontainer.status !== "deleting" && (
<AgentDevcontainerMoreActions
deleteDevContainer={deleteDevcontainerMutation.mutate}
/>

View File

@@ -17,6 +17,7 @@ const meta: Meta<typeof CreateUserForm> = {
onCancel: action("cancel"),
onSubmit: action("submit"),
isLoading: false,
serviceAccountsEnabled: true,
},
};

View File

@@ -87,6 +87,7 @@ interface CreateUserFormProps {
onCancel: () => void;
authMethods?: TypesGen.AuthMethods;
showOrganizations: boolean;
serviceAccountsEnabled: boolean;
}
export const CreateUserForm: FC<CreateUserFormProps> = ({
@@ -96,12 +97,13 @@ export const CreateUserForm: FC<CreateUserFormProps> = ({
onCancel,
showOrganizations,
authMethods,
serviceAccountsEnabled,
}) => {
const availableLoginTypes = [
authMethods?.password.enabled && "password",
authMethods?.oidc.enabled && "oidc",
authMethods?.github.enabled && "github",
"none",
serviceAccountsEnabled && "none",
].filter(Boolean) as Array<keyof typeof loginTypeOptions>;
const defaultLoginType = availableLoginTypes[0];

View File

@@ -6,6 +6,7 @@ import { getErrorDetail, getErrorMessage } from "#/api/errors";
import { authMethods, createUser } from "#/api/queries/users";
import { Margins } from "#/components/Margins/Margins";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
import { pageTitle } from "#/utils/page";
import { CreateUserForm } from "./CreateUserForm";
@@ -15,6 +16,7 @@ const CreateUserPage: FC = () => {
const createUserMutation = useMutation(createUser(queryClient));
const authMethodsQuery = useQuery(authMethods());
const { showOrganizations } = useDashboard();
const { service_accounts: serviceAccountsEnabled } = useFeatureVisibility();
return (
<Margins>
@@ -58,6 +60,7 @@ const CreateUserPage: FC = () => {
}}
authMethods={authMethodsQuery.data}
showOrganizations={showOrganizations}
serviceAccountsEnabled={serviceAccountsEnabled}
/>
</Margins>
);