diff --git a/.agents/skills/linear-release-setup/SKILL.md b/.agents/skills/linear-release-setup/SKILL.md new file mode 100644 index 00000000000..662fa46b63f --- /dev/null +++ b/.agents/skills/linear-release-setup/SKILL.md @@ -0,0 +1,106 @@ +--- +name: linear-release-setup +description: Generate CI/CD configuration for Linear Release. Use when setting up + release tracking, configuring CI pipelines for Linear, or integrating deployments + with Linear releases. Supports GitHub Actions, GitLab CI, CircleCI, and other platforms. +--- + +# Linear Release Setup + +The [linear-release README](https://github.com/linear/linear-release/blob/main/README.md) is the source of truth for commands, flags, installation, environment variables, path filtering, and troubleshooting. Fetch it before generating any config — this skill focuses on the interactive setup workflow and the pipeline modeling decisions the README cannot make for the user. + +## Interactive Workflow + +### Step 1: Preflight + +Before generating config, confirm: + +1. **Pipeline exists in Linear** — the user must have created a release pipeline in Linear first (Settings → Releases). Each pipeline has its own access key. +2. **Detect CI platform** — look for `.github/workflows/*.yml` (GitHub Actions), `.gitlab-ci.yml` (GitLab CI), `.circleci/config.yml` (CircleCI), or other CI config. +3. **Detect default branch** — check `git symbolic-ref refs/remotes/origin/HEAD` or the CI config. Don't assume `main`. + +### Step 2: Map pipelines, then ask + +Start by listing every build the user ships independently — each becomes its own Linear pipeline. Pipeline-vs-stage confusion is the single most common setup mistake, so whenever a split isn't obvious, apply the test in "Stages vs Pipelines" below. + +Ask, in order: + +1. **CI platform** — if not auto-detected. + +2. **What do you ship, and to whom?** Prompt explicitly about common split candidates: production vs. beta or TestFlight, nightly or dogfood builds, staging, per-platform builds (iOS, Android, web), per-service in a monorepo. For each candidate, apply the test: _can these hold different commits at the same time?_ Yes → separate pipelines. No (same immutable build moving through gates) → one pipeline with stages. + +3. **For each pipeline: continuous or scheduled?** + - **Continuous** — every deploy completes a release. Typical for nightlies, dogfood, and web apps that ship on merge. + - **Scheduled** — releases collect changes over time and move through stages before shipping. Typical for versioned mobile and on-prem. + +4. **For each scheduled pipeline, ask explicitly:** + - **Branch model** — just `main`, or `main` + release branches (`release/*`)? + - **Version source** — calendar (`2026.05`), semver (`1.2.0`), or commit SHA? Derived from branch name, CI variable, file, or git tag? + - **Stages** — what phases does a release move through before completion (e.g. "code freeze", "in qa")? Stages are gates on one build, not separate pipelines. + - **Automation** — all manual via `workflow_dispatch`, or automated (e.g. cutting a release branch auto-promotes it)? + +5. **Monorepo paths** — if multiple pipelines share one repo, note which paths belong to each and wire up path filters in Linear pipeline settings or via `--include-paths`. + +### Step 3: Generate the CI configuration + +Fetch the [README](https://github.com/linear/linear-release/blob/main/README.md) first for the current commands, flags, install snippet, and command-targeting rules. For GitHub Actions, prefer the official action (`linear/linear-release-action@v0`); for other platforms, use the CLI binary per the README's Installation section. + +Pick the matching example template, adapt it (branch patterns, stage names, paths, version format), and add it to an existing workflow or create a new one. Multiple pipelines mean multiple workflows or jobs, each calling the CLI with its own access key — one secret per pipeline (e.g. `LINEAR_ACCESS_KEY_IOS`, `LINEAR_ACCESS_KEY_WEB`). + +| Platform | Pipeline Type | Example | +| -------------- | ------------- | --------------------------------------------------------------------------------------------------------------------- | +| GitHub Actions | Continuous | [`github-actions-continuous/`](https://github.com/linear/linear-release/blob/main/examples/github-actions-continuous) | +| GitHub Actions | Scheduled | [`github-actions-scheduled/`](https://github.com/linear/linear-release/blob/main/examples/github-actions-scheduled) | +| GitLab CI | Continuous | [`gitlab-ci-continuous/`](https://github.com/linear/linear-release/blob/main/examples/gitlab-ci-continuous) | +| GitLab CI | Scheduled | [`gitlab-ci-scheduled/`](https://github.com/linear/linear-release/blob/main/examples/gitlab-ci-scheduled) | +| CircleCI | Continuous | [`circleci-continuous/`](https://github.com/linear/linear-release/blob/main/examples/circleci-continuous) | +| CircleCI | Scheduled | [`circleci-scheduled/`](https://github.com/linear/linear-release/blob/main/examples/circleci-scheduled) | + +Each scheduled example includes a **monorepo** note in the header explaining how to split workflows for path filtering per platform. + +### Step 4: Remind about secrets + +Tell the user to add the `LINEAR_ACCESS_KEY` secret to their CI environment: + +- **GitHub Actions**: Repository Settings → Secrets and variables → Actions → New repository secret +- **GitLab CI**: Settings → CI/CD → Variables +- **CircleCI**: Project Settings → Environment Variables + +The access key is created in Linear from the pipeline's settings page. Each pipeline has its own access key. + +## Key Concepts + +A Linear **release pipeline** is one independent stream of releases, with its own version history, current release, and access key. This is not a CI pipeline; it is the unit Linear uses to track releases, and your CI config calls the CLI to update it. Different products, environments, or distribution channels that ship independently are different pipelines. + +Pipelines come in two types — **continuous** and **scheduled**. See the README's [Pipeline Types](https://github.com/linear/linear-release#pipeline-types) section for the canonical description of each. + +### Stages vs Pipelines + +A **pipeline** is one stream of releases. A **stage** is one phase inside a release on that pipeline. Confusing the two is the single most common setup mistake — work through the test below before writing any config. + +**The test:** can two things be in-flight at the same time, holding different commits? + +- **Yes** → separate pipelines. TestFlight running on `HEAD` while production ships 1.2 from a release branch. Web staging auto-deploying from `main` while prod lags behind. A hotfix landing in one stream but not the other. +- **No, it's the same build moving through gates** → one pipeline with stages. A release is cut at 1.2, goes through code freeze, QA, and RC soak, then ships. The build never changes; only the phase does. + +Stages are process gates: "code freeze", "in qa", "in review", "rc soak". They only exist on scheduled pipelines. + +**Ambiguous cases — apply the test:** + +- **Beta / TestFlight.** TestFlight soak before GA on the _same build_ → stage on the production pipeline. A separate nightly or dogfood channel shipping _distinct builds_ → its own pipeline. +- **Staging.** Staging that auto-deploys from `main` (or runs hotfixes prod doesn't have) → separate pipeline. Staging that holds the exact same build as prod, just earlier in the promotion path → stage. +- **Per-service monorepo.** Each service that ships independently → its own pipeline, scoped by path filters. Unambiguous; services are never stages. + +Stages can also be **frozen** in Linear. A frozen stage makes `sync` (without `--release-version`) skip that release and land commits on the next one — a safety net for code freezes. This is a process tool, not a way to squeeze two pipelines into one. + +## Reference + +Everything about commands, flags, environment variables, command targeting, path filtering, JSON output, and troubleshooting lives in the [linear-release README](https://github.com/linear/linear-release#readme). For GitHub Action inputs and how they map to CLI flags, see the [action README](https://github.com/linear/linear-release-action#inputs). Always fetch these rather than relying on memory — they move ahead of this skill. + +### Checklist + +- [ ] Full clone / `fetch-depth: 0` +- [ ] `LINEAR_ACCESS_KEY` set as a secret (one per pipeline) +- [ ] Correct binary platform (`linux-x64`, `darwin-arm64`, or `darwin-x64`) +- [ ] Triggers on the correct branches (`main` for continuous; `main` + `release/*` for scheduled) +- [ ] Monorepo: path filters set (in Linear config or via `--include-paths`), and separate workflows if using release branches diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b315c224cf9..d85359590f6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -528,3 +528,53 @@ jobs: -H "Content-Type: application/json" \ -H "x-api-key: ${{ secrets.BUG0_SECRET_KEY }}" \ -d '{"url": "https://eu.dashboard.novu.co", "source": "novuhq-novu", "prod": "true"}' + + linear_release_cloud: + # Scheduled Linear pipeline: sync issues for this commit, then either move the + # release to the staging stage (staging deploys) or complete it (production). + # In Linear: set the Cloud pipeline to "Scheduled", add a stage named exactly + # "staging" (or change the `stage` input below to match your Linear stage name). + # Pass the same commit SHA as `version` from staging → prod for one release. + name: Linear Release (Cloud) + needs: [deploy, prepare-matrix] + runs-on: ubuntu-latest + if: | + always() && + needs.deploy.result == 'success' && + ( + github.event.inputs.environment == 'staging' || + github.event.inputs.environment == 'staging-sg' || + contains(github.event.inputs.environment, 'production') + ) + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Sync release to Linear + uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # pinned v0 ref + with: + access_key: ${{ secrets.LINEAR_ACCESS_KEY_CLOUD }} + version: ${{ github.sha }} + + - name: Update Linear stage — staging + if: | + github.event.inputs.environment == 'staging' || + github.event.inputs.environment == 'staging-sg' + uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # pinned v0 ref + with: + access_key: ${{ secrets.LINEAR_ACCESS_KEY_CLOUD }} + command: update + stage: staging + version: ${{ github.sha }} + + - name: Complete Linear release — production + if: contains(github.event.inputs.environment, 'production') + uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # pinned v0 ref + with: + access_key: ${{ secrets.LINEAR_ACCESS_KEY_CLOUD }} + command: complete + version: ${{ github.sha }} diff --git a/.github/workflows/linear-release-community.yml b/.github/workflows/linear-release-community.yml new file mode 100644 index 00000000000..a4747e7425e --- /dev/null +++ b/.github/workflows/linear-release-community.yml @@ -0,0 +1,34 @@ +name: Linear Release - Community Edition + +# Scheduled Linear pipeline for the Community / OSS self-hosted release stream. +# - Push to `next`: incrementally syncs commits/issues to the in-progress release. +# - Tag push (`v*.*.*`): handled inside `prepare-self-hosted-release.yml`, which +# syncs and completes the Linear release using the tag version after Docker +# images are built and pushed. + +on: + push: + branches: + - next + +permissions: + contents: read + +concurrency: + group: linear-release-community-${{ github.ref }} + cancel-in-progress: false + +jobs: + sync: + name: Sync to Linear (Community) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Sync release to Linear + uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # pinned v0 ref + with: + access_key: ${{ secrets.LINEAR_ACCESS_KEY_COMMUNITY }} diff --git a/.github/workflows/linear-release-enterprise.yml b/.github/workflows/linear-release-enterprise.yml new file mode 100644 index 00000000000..24f938daca9 --- /dev/null +++ b/.github/workflows/linear-release-enterprise.yml @@ -0,0 +1,34 @@ +name: Linear Release - Enterprise Edition + +# Scheduled Linear pipeline for the Enterprise self-hosted release stream. +# - Push to `next`: incrementally syncs commits/issues to the in-progress release. +# - Manual enterprise build: handled inside +# `prepare-enterprise-self-hosted-release.yml`, which syncs and completes the +# Linear release using the dispatched `version` input after images are built. + +on: + push: + branches: + - next + +permissions: + contents: read + +concurrency: + group: linear-release-enterprise-${{ github.ref }} + cancel-in-progress: false + +jobs: + sync: + name: Sync to Linear (Enterprise) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Sync release to Linear + uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # pinned v0 ref + with: + access_key: ${{ secrets.LINEAR_ACCESS_KEY_ENTERPRISE }} diff --git a/.github/workflows/prepare-enterprise-self-hosted-release.yml b/.github/workflows/prepare-enterprise-self-hosted-release.yml index 34f02ecfa08..a44fd7256cb 100644 --- a/.github/workflows/prepare-enterprise-self-hosted-release.yml +++ b/.github/workflows/prepare-enterprise-self-hosted-release.yml @@ -252,3 +252,36 @@ jobs: echo " - $REGISTRY/novu/$REPO_NAME:$IMAGE_TAG" echo "Platform: linux/amd64 (x86)" echo "Type: Enterprise Self-hosted" + + linear_release_complete: + name: Complete Linear Release (Enterprise) + needs: build_docker + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Normalize Linear release version + env: + RAW_VERSION: ${{ github.event.inputs.version }} + run: | + ver="${RAW_VERSION#v}" + echo "LINEAR_RELEASE_VERSION=$ver" >> "$GITHUB_ENV" + + - name: Sync release to Linear + uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # pinned v0 ref + with: + access_key: ${{ secrets.LINEAR_ACCESS_KEY_ENTERPRISE }} + name: v${{ env.LINEAR_RELEASE_VERSION }} + version: ${{ env.LINEAR_RELEASE_VERSION }} + + - name: Complete release in Linear + uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # pinned v0 ref + with: + access_key: ${{ secrets.LINEAR_ACCESS_KEY_ENTERPRISE }} + command: complete + version: ${{ env.LINEAR_RELEASE_VERSION }} diff --git a/.github/workflows/prepare-self-hosted-release.yml b/.github/workflows/prepare-self-hosted-release.yml index fd1dba3fda4..cb220236340 100644 --- a/.github/workflows/prepare-self-hosted-release.yml +++ b/.github/workflows/prepare-self-hosted-release.yml @@ -238,3 +238,33 @@ jobs: docker tag novu-$SERVICE_COMMON_NAME ghcr.io/$REGISTRY_OWNER/${{ matrix.name }}:latest docker push ghcr.io/$REGISTRY_OWNER/${{ matrix.name }}:latest fi + + linear_release_complete: + name: Complete Linear Release (Community) + needs: build_docker + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set release version + run: echo "RELEASE_VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + + - name: Sync release to Linear + uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # pinned v0 ref + with: + access_key: ${{ secrets.LINEAR_ACCESS_KEY_COMMUNITY }} + name: v${{ env.RELEASE_VERSION }} + version: ${{ env.RELEASE_VERSION }} + + - name: Complete release in Linear + uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # pinned v0 ref + with: + access_key: ${{ secrets.LINEAR_ACCESS_KEY_COMMUNITY }} + command: complete + version: ${{ env.RELEASE_VERSION }} diff --git a/.windsurf/skills/linear-release-setup b/.windsurf/skills/linear-release-setup new file mode 120000 index 00000000000..ebd6af5d347 --- /dev/null +++ b/.windsurf/skills/linear-release-setup @@ -0,0 +1 @@ +../../.agents/skills/linear-release-setup \ No newline at end of file diff --git a/apps/api/src/app/inbox/usecases/session/session.spec.ts b/apps/api/src/app/inbox/usecases/session/session.spec.ts index d08925f36fd..5f9cd2ddd12 100644 --- a/apps/api/src/app/inbox/usecases/session/session.spec.ts +++ b/apps/api/src/app/inbox/usecases/session/session.spec.ts @@ -19,7 +19,7 @@ import { NotificationTemplateRepository, PreferencesRepository, } from '@novu/dal'; -import { ApiServiceLevelEnum, ChannelTypeEnum, InAppProviderIdEnum, SeverityLevelEnum } from '@novu/shared'; +import { ApiServiceLevelEnum, ChannelTypeEnum, EnvironmentTypeEnum, InAppProviderIdEnum, SeverityLevelEnum } from '@novu/shared'; import { expect } from 'chai'; import sinon from 'sinon'; import { AuthService } from '../../../auth/services/auth.service'; @@ -136,6 +136,85 @@ describe('Session', () => { messageRepository.getCountBySeverity.resolves(mockSeverityCounts); }); + it('should set isDevelopmentMode to false for live (prod type) environment regardless of display name', async () => { + const command: SessionCommand = { + requestData: { + applicationIdentifier: 'app-id', + subscriber: { subscriberId: 'subscriber-id' }, + subscriberHash: 'hash', + }, + }; + + const environment = { + _id: 'env-id', + _organizationId: 'org-id', + name: 'Third environment', + type: EnvironmentTypeEnum.PROD, + apiKeys: [{ key: 'api-key' }], + }; + const organization = { _id: 'org-id', apiServiceLevel: ApiServiceLevelEnum.FREE }; + const subscriber = { _id: 'subscriber-id' }; + const notificationCount = { data: [{ count: 0, filter: {} }] }; + const token = 'token'; + + environmentRepository.findEnvironmentByIdentifier.resolves(environment as any); + organizationRepository.findById.resolves(organization as any); + selectIntegration.execute.resolves({ ...mockIntegration, credentials: { hmac: false } }); + createSubscriber.execute.resolves(subscriber as any); + notificationsCount.execute.resolves(notificationCount); + authService.getSubscriberWidgetToken.resolves(token); + getOrganizationSettingsUsecase.execute.resolves({ + removeNovuBranding: false, + defaultLocale: 'en_US', + }); + + const response: SubscriberSessionResponseDto = await session.execute(command); + + expect(response.isDevelopmentMode).to.equal(false); + }); + + it('should fall back to legacy name check when environment type is unset', async () => { + const command: SessionCommand = { + requestData: { + applicationIdentifier: 'app-id', + subscriber: { subscriberId: 'subscriber-id' }, + subscriberHash: 'hash', + }, + }; + + const organization = { _id: 'org-id', apiServiceLevel: ApiServiceLevelEnum.FREE }; + const subscriber = { _id: 'subscriber-id' }; + const notificationCount = { data: [{ count: 0, filter: {} }] }; + const token = 'token'; + + const legacyProdNamed = { + _id: 'env-id', + _organizationId: 'org-id', + name: 'Production', + apiKeys: [{ key: 'api-key' }], + }; + + environmentRepository.findEnvironmentByIdentifier.resolves(legacyProdNamed as any); + organizationRepository.findById.resolves(organization as any); + selectIntegration.execute.resolves({ ...mockIntegration, credentials: { hmac: false } }); + createSubscriber.execute.resolves(subscriber as any); + notificationsCount.execute.resolves(notificationCount); + authService.getSubscriberWidgetToken.resolves(token); + getOrganizationSettingsUsecase.execute.resolves({ + removeNovuBranding: false, + defaultLocale: 'en_US', + }); + + const prodResponse: SubscriberSessionResponseDto = await session.execute(command); + expect(prodResponse.isDevelopmentMode).to.equal(false); + + const legacyDevNamed = { ...legacyProdNamed, name: 'Staging' }; + environmentRepository.findEnvironmentByIdentifier.resolves(legacyDevNamed as any); + + const devResponse: SubscriberSessionResponseDto = await session.execute(command); + expect(devResponse.isDevelopmentMode).to.equal(true); + }); + it('should throw an error if the environment is not found', async () => { const command: SessionCommand = { requestData: { diff --git a/apps/api/src/app/inbox/usecases/session/session.usecase.ts b/apps/api/src/app/inbox/usecases/session/session.usecase.ts index 3da283783e9..021b0c9c15d 100644 --- a/apps/api/src/app/inbox/usecases/session/session.usecase.ts +++ b/apps/api/src/app/inbox/usecases/session/session.usecase.ts @@ -45,6 +45,7 @@ import { FeatureNameEnum, getFeatureForTierAsNumber, InAppProviderIdEnum, + EnvironmentTypeEnum, PreferenceLevelEnum, PreferencesTypeEnum, ResourceOriginEnum, @@ -283,7 +284,7 @@ export class Session { unreadCount, removeNovuBranding, maxSnoozeDurationHours, - isDevelopmentMode: environment.name.toLowerCase() !== 'production', + isDevelopmentMode: this.isInboxDevelopmentMode(environment), schedule, contextKeys, }; @@ -329,6 +330,22 @@ export class Session { return updatedGlobalPreference.schedule; } + /** + * Live (production-type) environments must not show the Inbox "Development mode" footer, + * regardless of display name. Legacy orgs may lack `type`; fall back to the old name check. + */ + private isInboxDevelopmentMode(environment: EnvironmentEntity): boolean { + if (environment.type === EnvironmentTypeEnum.PROD) { + return false; + } + + if (environment.type === EnvironmentTypeEnum.DEV) { + return true; + } + + return environment.name.toLowerCase() !== 'production'; + } + private validateRequestData(requestData: SubscriberSessionRequestDto): void { if (!requestData.applicationIdentifier && this.extractSubscriberInfo(requestData, true)?.subscriberId) { throw new UnprocessableEntityException( diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000000..25ff39f4324 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "linear-release-setup": { + "source": "linear/linear-release", + "sourceType": "github", + "skillPath": "skills/linear-release-setup/SKILL.md", + "computedHash": "4af0fca2bc5abe8847eb74eee5e5372ada84ca674ee7c84a75f5809bbb7367a8" + } + } +}