Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 10 additions & 14 deletions .agents/rules/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,13 @@ Ant Design is the source of truth for theming.

## CSS property order

Object style props (`style` and Ant Design `styles`) follow recess order:

1. `content`
2. Positioning: `position`, `top`, `right`, `bottom`, `left`, `zIndex`
3. Box model: `display`, `overflow`, `width`, `height`, `padding`, `margin`
4. Flex/Grid: `flex`, `flexDirection`, `alignItems`, `justifyContent`, `gap`
5. Border: `border*`, `borderRadius`, `boxShadow`, `outline`
6. Background and color: `background*`, `color`, `opacity`
7. Typography: `font*`, `lineHeight`, `textAlign`, `textOverflow`, `whiteSpace`
8. UI: `cursor`, `pointerEvents`, `userSelect`
9. SVG: `fill`, `stroke`
10. Transform and animation: `transform`, `transition`, `animation`

The local ESLint rule `local/style-props-recess-order` enforces and can auto-fix this ordering.
Keep object style props (`style` and Ant Design `styles`) in alphabetical order.
The local ESLint rule `local/style-props-alphabetical-order` warns and can
auto-fix the ordering. Objects containing spread or computed properties are
left unchanged because reordering them could change behavior.

Do not mix a CSS shorthand with one of its longhands in the same object, such
as `margin` with `marginTop`. The local rule
`local/style-props-no-shorthand-conflicts` reports these ambiguous overrides as
errors. Duplicate object keys are errors through ESLint's standard
`no-dupe-keys` rule.
7 changes: 4 additions & 3 deletions .agents/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ Use the configured project checks first:
npm run typecheck
npm run lint
npm run test:coverage
npm run test:e2e
npm run build
npm run test:contract
npm run test:e2e -- --project=chromium
npm run performance
npm run knip
```

## Choosing a test layer

If the project gains a test setup, use this decision tree:
Use this decision tree when choosing the existing test setup:

- Pure utility or mapper: unit test.
- Hook that composes state or side effects: integration test.
Expand Down
13 changes: 8 additions & 5 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,25 @@
/.github/ @sepaseh
/package.json @sepaseh
/package-lock.json @sepaseh
/sonar-project.properties @sepaseh
/docs/sonarqube.md @sepaseh

# Release policy, version state, and generated release notes.
# Optional manual release and environment tooling.
/.release-please-manifest.json @sepaseh
/CHANGELOG.md @sepaseh
/docs/release-operations.md @sepaseh
/docs/releasing.md @sepaseh
/release-please-config.json @sepaseh

# Production-like staging gate.
/.github/workflows/staging.yml @sepaseh
/docs/staging.md @sepaseh
/staging.config.ts @sepaseh
/staging/ @sepaseh

# Security model, review evidence, and active scanning.
/.github/workflows/deployment-smoke.yml @sepaseh
/smoke.config.ts @sepaseh
/smoke/ @sepaseh
/.github/workflows/dast.yml @sepaseh

# Security model and review evidence.
/docs/security/ @sepaseh
/SECURITY.md @sepaseh

Expand Down
48 changes: 19 additions & 29 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@ concurrency:
permissions:
contents: read

env:
NODE_VERSION: 24.18.0

jobs:
check:
runs-on: ubuntu-latest
Expand All @@ -31,7 +28,7 @@ jobs:

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ env.NODE_VERSION }}
node-version-file: .nvmrc
cache: npm

- run: npm ci
Expand All @@ -55,40 +52,29 @@ jobs:
path: pacts
retention-days: 14

- name: Validate SonarQube configuration
continue-on-error: true
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
- name: Detect SonarQube configuration
id: sonar-config
env:
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
SONAR_PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |
missing=()

[[ -z "$SONAR_HOST_URL" ]] && missing+=("SONAR_HOST_URL")
[[ -z "$SONAR_PROJECT_KEY" ]] && missing+=("SONAR_PROJECT_KEY")
[[ -z "$SONAR_TOKEN" ]] && missing+=("SONAR_TOKEN")

if (( ${#missing[@]} > 0 )); then
missing_list=$(IFS=,; echo "${missing[*]}")
echo "::error title=Missing SonarQube configuration::Configure these repository settings: $missing_list"
exit 1
if [[ -n "$SONAR_HOST_URL" && -n "$SONAR_PROJECT_KEY" && -n "$SONAR_TOKEN" ]]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::notice title=SonarQube skipped::Repository settings are not available for this run."
fi

- name: SonarQube scan
- name: SonarQube advisory scan
if: steps.sonar-config.outputs.enabled == 'true'
continue-on-error: true
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
uses: SonarSource/sonarqube-scan-action@7006c4492b2e0ee0f816d36501671557c97f5995 # v8.1.0
env:
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY }}
with:
args: -Dsonar.projectKey=${{ env.SONAR_PROJECT_KEY }}
args: -Dsonar.projectKey=${{ vars.SONAR_PROJECT_KEY }}

- run: npm run knip

Expand All @@ -107,13 +93,17 @@ jobs:

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ env.NODE_VERSION }}
node-version-file: .nvmrc
cache: npm

- run: npm ci

- run: npm run performance

- name: Lighthouse advisory audit
continue-on-error: true
run: npm run lighthouse

- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: dist-${{ github.sha }}
Expand Down Expand Up @@ -141,14 +131,14 @@ jobs:

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ env.NODE_VERSION }}
node-version-file: .nvmrc
cache: npm

- run: npm ci

- run: npx playwright install --with-deps chromium firefox webkit
- run: npx playwright install --with-deps chromium

- run: npm run test:e2e
- run: npm run test:e2e -- --project=chromium

- if: ${{ !cancelled() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
Expand Down
41 changes: 31 additions & 10 deletions .github/workflows/dast.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
name: DAST

on:
schedule:
- cron: "0 3 1 * *"
workflow_dispatch:

inputs:
target_url:
description: Authorized staging application URL
required: true
type: string
concurrency:
cancel-in-progress: false
group: staging-dast
Expand All @@ -14,25 +16,44 @@ permissions:

jobs:
zap:
environment: staging
runs-on: ubuntu-latest
timeout-minutes: 60
env:
DAST_ALLOWED_HOST: ${{ vars.STAGING_ALLOWED_HOST }}
DAST_ALLOWED_HOST: ${{ vars.STAGING_ALLOWED_APP_HOST }}
DAST_PRODUCTION_HOST: ${{ vars.PRODUCTION_HOST }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
DAST_TARGET: ${{ vars.STAGING_BASE_URL }}
DAST_TARGET: ${{ inputs.target_url }}

steps:
- name: Validate authorized target
shell: bash
run: |
node -e '
const value = process.env.DAST_TARGET;
const allowedHost = process.env.DAST_ALLOWED_HOST?.toLowerCase();
const productionHost = process.env.DAST_PRODUCTION_HOST?.toLowerCase();
const parseConfiguredHostname = (name, rawValue) => {
if (!rawValue || rawValue !== rawValue.trim()) {
throw new Error(`${name} must be a hostname without whitespace`);
}
const normalized = rawValue.toLowerCase();
const hostnamePattern =
/^(?=.{1,253}$)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
if (!hostnamePattern.test(normalized)) {
throw new Error(`${name} must contain a hostname only`);
}
const parsed = new URL(`https://${rawValue}`);
if (parsed.hostname !== normalized) {
throw new Error(`${name} must be a canonical hostname only`);
}
return parsed.hostname;
};
if (!value) throw new Error("STAGING_BASE_URL is required");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if (!allowedHost) throw new Error("STAGING_ALLOWED_HOST is required");
if (!productionHost) throw new Error("PRODUCTION_HOST is required");
const allowedHost = parseConfiguredHostname(
"STAGING_ALLOWED_APP_HOST",
process.env.DAST_ALLOWED_HOST,
);
const productionHost = parseConfiguredHostname(
"PRODUCTION_HOST",
process.env.DAST_PRODUCTION_HOST,
);
const url = new URL(value);
if (url.protocol !== "https:") {
throw new Error("STAGING_BASE_URL must use HTTPS");
Expand Down
89 changes: 71 additions & 18 deletions .github/workflows/deployment-smoke.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
name: Deployment smoke tests

on:
deployment_status:
workflow_dispatch:
inputs:
application_url:
Expand All @@ -12,44 +11,98 @@ on:
description: Public API health endpoint
required: true
type: string
deployment_id:
description: Immutable identifier of the deployed release
required: true
type: string

concurrency:
cancel-in-progress: true
group: deployment-smoke-${{ github.event.deployment.environment || inputs.application_url }}
group: deployment-smoke-${{ inputs.application_url }}

permissions:
contents: read

jobs:
smoke:
if: >-
github.event_name == 'workflow_dispatch' ||
github.event.deployment_status.state == 'success'
runs-on: ubuntu-latest
env:
NODE_VERSION: 24.18.0
SMOKE_API_HEALTH_URL: >-
${{ github.event_name == 'workflow_dispatch' &&
inputs.api_health_url || vars.SMOKE_API_HEALTH_URL }}
SMOKE_BASE_URL: >-
${{ github.event_name == 'workflow_dispatch' &&
inputs.application_url ||
github.event.deployment_status.environment_url }}
SMOKE_ALLOWED_API_HOST: ${{ vars.SMOKE_ALLOWED_API_HOST }}
SMOKE_ALLOWED_APP_HOST: ${{ vars.SMOKE_ALLOWED_APP_HOST }}
SMOKE_API_HEALTH_URL: ${{ inputs.api_health_url }}
SMOKE_BASE_URL: ${{ inputs.application_url }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
SMOKE_DEPLOYMENT_ID: ${{ inputs.deployment_id }}
SMOKE_EXPECTED_DEPLOYMENT_ID: ${{ vars.SMOKE_EXPECTED_DEPLOYMENT_ID }}

steps:
- name: Validate smoke-test targets
shell: bash
run: |
if [[ -z "$SMOKE_BASE_URL" || -z "$SMOKE_API_HEALTH_URL" ]]; then
echo "SMOKE_BASE_URL and SMOKE_API_HEALTH_URL are required."
exit 1
fi
node -e '
const parseConfiguredHostname = (name, rawValue) => {
if (!rawValue || rawValue !== rawValue.trim()) {
throw new Error(`${name} must be a hostname without whitespace`);
}
const normalized = rawValue.toLowerCase();
const hostnamePattern =
/^(?=.{1,253}$)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
if (!hostnamePattern.test(normalized)) {
throw new Error(`${name} must contain a hostname only`);
}
const parsed = new URL(`https://${rawValue}`);
if (parsed.hostname !== normalized) {
throw new Error(`${name} must be a canonical hostname only`);
}
return parsed.hostname;
};
const parseTarget = (name, rawValue) => {
if (!rawValue) throw new Error(`${name} is required`);
const url = new URL(rawValue);
if (url.protocol !== "https:") {
throw new Error(`${name} must use HTTPS`);
}
if (url.username || url.password) {
throw new Error(`${name} must not contain credentials`);
}
return url;
};
const appUrl = parseTarget("SMOKE_BASE_URL", process.env.SMOKE_BASE_URL);
const apiUrl = parseTarget(
"SMOKE_API_HEALTH_URL",
process.env.SMOKE_API_HEALTH_URL,
);
const allowedAppHost = parseConfiguredHostname(
"SMOKE_ALLOWED_APP_HOST",
process.env.SMOKE_ALLOWED_APP_HOST,
);
const allowedApiHost = parseConfiguredHostname(
"SMOKE_ALLOWED_API_HOST",
process.env.SMOKE_ALLOWED_API_HOST,
);
if (appUrl.hostname !== allowedAppHost) {
throw new Error("SMOKE_BASE_URL host is not explicitly allowed");
}
if (apiUrl.hostname !== allowedApiHost) {
throw new Error("SMOKE_API_HEALTH_URL host is not explicitly allowed");
}
const deploymentId = process.env.SMOKE_DEPLOYMENT_ID;
const expectedDeploymentId = process.env.SMOKE_EXPECTED_DEPLOYMENT_ID;
if (!deploymentId || !expectedDeploymentId) {
throw new Error(
"SMOKE_DEPLOYMENT_ID and SMOKE_EXPECTED_DEPLOYMENT_ID are required",
);
}
if (deploymentId !== expectedDeploymentId) {
throw new Error("The smoke targets do not match the expected deployment");
}
'

- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
cache: npm
node-version: ${{ env.NODE_VERSION }}
node-version-file: .nvmrc

- run: npm ci

Expand Down
Loading
Loading