Compare commits
6 Commits
pubsub-buf
...
fix/devcon
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0ede17bbe | ||
|
|
48bc215f20 | ||
|
|
08bd9e672a | ||
|
|
c5f1a2fccf | ||
|
|
655d647d40 | ||
|
|
f3f0a2c553 |
26
.github/workflows/release.yaml
vendored
26
.github/workflows/release.yaml
vendored
@@ -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
|
||||
|
||||
@@ -134,6 +134,7 @@ func TestUserCreate(t *testing.T) {
|
||||
{
|
||||
name: "ServiceAccount",
|
||||
args: []string{"--service-account", "-u", "dean"},
|
||||
err: "Premium feature",
|
||||
},
|
||||
{
|
||||
name: "ServiceAccountLoginType",
|
||||
|
||||
@@ -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!")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
</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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(¬es, "\n_No changes since %s._\n", prevStr)
|
||||
}
|
||||
|
||||
// Compare link.
|
||||
if changelogBaseRef != "" {
|
||||
compareBase := changelogBaseRef
|
||||
if prevVersion != nil {
|
||||
compareBase = prevVersion.String()
|
||||
}
|
||||
if compareBase != "" {
|
||||
fmt.Fprintf(¬es, "\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.
|
||||
|
||||
2
site/src/api/typesGenerated.ts
generated
2
site/src/api/typesGenerated.ts
generated
@@ -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",
|
||||
|
||||
@@ -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)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -274,7 +274,7 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDevcontainerControls && (
|
||||
{devcontainer.status !== "deleting" && (
|
||||
<AgentDevcontainerMoreActions
|
||||
deleteDevContainer={deleteDevcontainerMutation.mutate}
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,7 @@ const meta: Meta<typeof CreateUserForm> = {
|
||||
onCancel: action("cancel"),
|
||||
onSubmit: action("submit"),
|
||||
isLoading: false,
|
||||
serviceAccountsEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user