diff --git a/.github/scripts/validate-config.sh b/.github/scripts/validate-config.sh index 53a6cd42..e194535d 100755 --- a/.github/scripts/validate-config.sh +++ b/.github/scripts/validate-config.sh @@ -20,10 +20,8 @@ npm i -g ajv-cli echo "### Validating JSON Schema" >> $GITHUB_STEP_SUMMARY -# Capture output without exiting on failure -AJV_OUTPUT=$(ajv validate -s "$SCHEMA_FILE" -d "$CONFIG_FILE" --all-errors 2>&1 || true) - -if [[ "$AJV_OUTPUT" != *"valid"* ]]; then +# Capture output and fail on non-zero exit +if ! AJV_OUTPUT=$(ajv validate -s "$SCHEMA_FILE" -d "$CONFIG_FILE" --all-errors 2>&1); then echo "$AJV_OUTPUT" # Show in logs echo "::error file=$CONFIG_FILE,title=Schema Validation Failed::${AJV_OUTPUT//$'\n'/ }" @@ -43,23 +41,28 @@ fi echo "✅ Schema validation passed" >> $GITHUB_STEP_SUMMARY # 2. Check version was incremented -PR_VERSION=$(jq -r '.version' $CONFIG_FILE) -MAIN_VERSION=$(git show origin/$MAIN_BRANCH:$CONFIG_FILE | jq -r '.version') - echo "### Version Check" >> $GITHUB_STEP_SUMMARY -echo "Main: v$MAIN_VERSION → PR: v$PR_VERSION" >> $GITHUB_STEP_SUMMARY -if [ "$PR_VERSION" -le "$MAIN_VERSION" ]; then - echo "::error file=$CONFIG_FILE,title=Version Not Incremented::Version must be incremented. Main=$MAIN_VERSION, PR=$PR_VERSION" +if git diff --name-only origin/$MAIN_BRANCH...HEAD | grep -Fx "$CONFIG_FILE" >/dev/null; then + PR_VERSION=$(jq -r '.version' $CONFIG_FILE) + MAIN_VERSION=$(git show origin/$MAIN_BRANCH:$CONFIG_FILE | jq -r '.version') - echo "❌ **Version not incremented!**" >> $GITHUB_STEP_SUMMARY + echo "Main: v$MAIN_VERSION → PR: v$PR_VERSION" >> $GITHUB_STEP_SUMMARY - echo "VALIDATION_ERROR=version" >> $GITHUB_ENV - echo "ERROR_MESSAGE=Version must be incremented! Main branch has version $MAIN_VERSION, your PR has version $PR_VERSION" >> $GITHUB_ENV - exit 1 -fi + if [ "$PR_VERSION" -le "$MAIN_VERSION" ]; then + echo "::error file=$CONFIG_FILE,title=Version Not Incremented::Version must be incremented. Main=$MAIN_VERSION, PR=$PR_VERSION" -echo "✅ Version correctly incremented" >> $GITHUB_STEP_SUMMARY + echo "❌ **Version not incremented!**" >> $GITHUB_STEP_SUMMARY + + echo "VALIDATION_ERROR=version" >> $GITHUB_ENV + echo "ERROR_MESSAGE=Version must be incremented! Main branch has version $MAIN_VERSION, your PR has version $PR_VERSION" >> $GITHUB_ENV + exit 1 + fi + + echo "✅ Version correctly incremented" >> $GITHUB_STEP_SUMMARY +else + echo "Config file unchanged in this PR; skipping version check." >> $GITHUB_STEP_SUMMARY +fi # 3. Check all rule references exist echo "### Rule Reference Check" >> $GITHUB_STEP_SUMMARY @@ -98,4 +101,4 @@ fi echo "✅ All rule references are valid" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY -echo "### ✅ All validations passed!" >> $GITHUB_STEP_SUMMARY \ No newline at end of file +echo "### ✅ All validations passed!" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/macos-PR-to-staging.yml b/.github/workflows/macos-PR-to-staging.yml index b52c2fcc..669fc617 100644 --- a/.github/workflows/macos-PR-to-staging.yml +++ b/.github/workflows/macos-PR-to-staging.yml @@ -1,32 +1,102 @@ name: PR to staging (macOS) on: - push: - paths: - - 'live/macos-config/macos-config.json' pull_request: - types: - - opened paths: - 'live/macos-config/macos-config.json' + - 'schemas/macos/schema.json' + +env: + CONFIG_FILE: 'live/macos-config/macos-config.json' + SCHEMA_FILE: 'schemas/macos/schema.json' jobs: + validate: + name: Validate Config + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Validate JSON and check version + id: validate + run: ./.github/scripts/validate-config.sh "$CONFIG_FILE" "$SCHEMA_FILE" main + + - name: Comment PR with error details + if: failure() + uses: actions/github-script@v7 + with: + script: | + const errorType = process.env.VALIDATION_ERROR; + const errorMessage = process.env.ERROR_MESSAGE; + + let title = '### ❌ Validation Failed\n\n'; + let body = ''; + + switch(errorType) { + case 'schema': + body = `**JSON Schema Validation Error**\n\n`; + body += 'Your JSON file does not match the required schema:\n\n'; + body += '```\n' + errorMessage + '\n```\n\n'; + body += '**How to fix:**\n'; + body += '1. Check the error message above\n'; + body += `2. Validate locally: \`ajv validate -s ${process.env.SCHEMA_FILE} -d ${process.env.CONFIG_FILE}\`\n`; + break; + + case 'version': + body = `**Version Not Incremented**\n\n`; + body += '⚠️ ' + errorMessage + '\n\n'; + body += '**How to fix:**\n'; + body += 'Increment the version number in the JSON config file\n'; + break; + + case 'rules': + body = `**Invalid Rule References**\n\n`; + body += 'Some messages reference rule IDs that don\'t exist:\n\n'; + body += '```\n' + errorMessage + '\n```\n\n'; + body += '**How to fix:**\n'; + body += '1. Check the rule IDs in your matchingRules/exclusionRules\n'; + body += '2. Ensure all referenced rules exist in the rules array\n'; + break; + } + + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: title + body + }); + publish: + name: Publish to Staging + needs: validate runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: jakejarvis/s3-sync-action@7ed8b112447abb09f1da74f3466e4194fc7a6311 - if: github.event_name == 'push' - with: - args: --acl public-read --follow-symlinks - env: - AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - SOURCE_DIR: 'live/macos-config' - DEST_DIR: 'remotemessaging/config/staging' - - uses: github-actions-up-and-running/pr-comment@f1f8ab2bf00dce6880a369ce08758a60c61d6c0b - if: github.event.action == 'opened' - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - message: 'Your PR is hosted at https://staticcdn.duckduckgo.com/remotemessaging/config/staging/macos-config.json' + - uses: actions/checkout@v4 + + - name: Sync to S3 staging + uses: jakejarvis/s3-sync-action@7ed8b112447abb09f1da74f3466e4194fc7a6311 + with: + args: --acl public-read --follow-symlinks + env: + AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + SOURCE_DIR: 'live/macos-config' + DEST_DIR: 'remotemessaging/config/staging' + + - name: Comment on PR with staging URL + uses: github-actions-up-and-running/pr-comment@f1f8ab2bf00dce6880a369ce08758a60c61d6c0b + if: github.event.action == 'opened' + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + message: | + ✅ Config validation passed! + + Your PR config is hosted at: + https://staticcdn.duckduckgo.com/remotemessaging/config/staging/macos-config.json diff --git a/schemas/ios/schema.json b/schemas/ios/schema.json index 08bf8103..5d2e63c1 100644 --- a/schemas/ios/schema.json +++ b/schemas/ios/schema.json @@ -230,7 +230,11 @@ "duckPlayerEnabled": { "$ref": "#/definitions/SingleValueBooleanAttribute" }, "messageShown": { "$ref": "#/definitions/SingleValueStringArrayAttribute" }, "isCurrentFreemiumPIRUser": { "$ref": "#/definitions/SingleValueBooleanAttribute" }, - "allFeatureFlagsEnabled": { "$ref": "#/definitions/SingleValueStringArrayAttribute" } + "allFeatureFlagsEnabled": { "$ref": "#/definitions/SingleValueStringArrayAttribute" }, + "subscriptionFreeTrialActive": { "$ref": "#/definitions/SingleValueBooleanAttribute" }, + "syncEnabled": { "$ref": "#/definitions/SingleValueBooleanAttribute" }, + "shouldShowWinBackOfferUrgencyMessage": { "$ref": "#/definitions/SingleValueBooleanAttribute" }, + "daysSinceDuckAiUsed": { "$ref": "#/definitions/NumericRangeAttribute" } }, "additionalProperties": false } diff --git a/schemas/macos/README.md b/schemas/macos/README.md new file mode 100644 index 00000000..8c0e0d95 --- /dev/null +++ b/schemas/macos/README.md @@ -0,0 +1,8 @@ +## Install + +* `brew install node` +* `npm install -g ajv-cli` + +## Run + +* `ajv validate -s schema.json -d ../../live/macos-config/macos-config.json` diff --git a/schemas/macos/schema.json b/schemas/macos/schema.json new file mode 100644 index 00000000..339f1a80 --- /dev/null +++ b/schemas/macos/schema.json @@ -0,0 +1,350 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Remote Messaging Configuration", + "description": "JSON schema for DuckDuckGo remote messaging configuration", + "type": "object", + "required": ["version", "messages", "rules"], + "additionalProperties": false, + "properties": { + "version": { + "type": "integer", + "description": "Configuration version number" + }, + "messages": { + "type": "array", + "description": "Array of remote messages", + "items": { + "$ref": "#/definitions/RemoteMessage" + } + }, + "rules": { + "type": "array", + "description": "Array of matching rules", + "items": { + "$ref": "#/definitions/MatchingRule" + } + } + }, + "definitions": { + "RemoteMessage": { + "type": "object", + "required": ["id", "content"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the message" + }, + "content": { + "$ref": "#/definitions/MessageContent" + }, + "translations": { + "type": "object", + "description": "Localized content translations", + "patternProperties": { + "^[a-z]{2}-[A-Z]{2}$": { + "$ref": "#/definitions/ContentTranslation" + } + }, + "additionalProperties": false + }, + "matchingRules": { + "type": "array", + "description": "Array of rule IDs that must match for this message to be shown", + "items": { + "type": "integer" + } + }, + "exclusionRules": { + "type": "array", + "description": "Array of rule IDs that must not match for this message to be shown", + "items": { + "type": "integer" + } + }, + "metrics": { + "$ref": "#/definitions/Metrics" + } + } + }, + "MessageContent": { + "type": "object", + "required": ["messageType", "titleText", "descriptionText"], + "additionalProperties": false, + "properties": { + "messageType": { + "type": "string", + "enum": ["small", "medium", "big_single_action", "big_two_action", "promo_single_action"], + "description": "Type of message to display" + }, + "titleText": { + "type": "string", + "minLength": 1, + "description": "Title text for the message" + }, + "descriptionText": { + "type": "string", + "minLength": 1, + "description": "Description text for the message" + }, + "placeholder": { + "type": "string", + "enum": ["Announce", "DDGAnnounce", "CriticalUpdate", "AppUpdate", "MacComputer", "NewForMacAndWindows", "PrivacyShield", "Duck.ai", "VisualDesignUpdate"], + "description": "Placeholder type for the message" + }, + "actionText": { + "type": "string", + "description": "Text for the action button (used in promo_single_action)" + }, + "action": { + "$ref": "#/definitions/MessageAction", + "description": "Action for the button (used in promo_single_action)" + }, + "primaryActionText": { + "type": "string", + "description": "Text for the primary action button (used in big_single_action and big_two_action)" + }, + "primaryAction": { + "$ref": "#/definitions/MessageAction", + "description": "Primary action (used in big_single_action and big_two_action)" + }, + "secondaryActionText": { + "type": "string", + "description": "Text for the secondary action button (used in big_two_action)" + }, + "secondaryAction": { + "$ref": "#/definitions/MessageAction", + "description": "Secondary action (used in big_two_action)" + } + } + }, + "MessageAction": { + "type": "object", + "required": ["type", "value"], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": ["share", "url", "appstore", "dismiss", "survey", "navigation"], + "description": "Type of action to perform" + }, + "value": { + "type": "string", + "description": "Value for the action (URL, navigation target, etc.)" + }, + "additionalParameters": { + "type": "object", + "description": "Additional parameters for the action", + "additionalProperties": false, + "properties": { + "title": { + "type": "string", + "description": "Title for share action" + }, + "queryParams": { + "type": "string", + "description": "Semicolon-separated query parameters for survey action" + } + } + } + } + }, + "ContentTranslation": { + "type": "object", + "additionalProperties": false, + "properties": { + "messageType": { + "type": "string", + "enum": ["small", "medium", "big_single_action", "big_two_action", "promo_single_action"] + }, + "titleText": { + "type": "string" + }, + "descriptionText": { + "type": "string" + }, + "primaryActionText": { + "type": "string" + }, + "secondaryActionText": { + "type": "string" + } + } + }, + "Metrics": { + "type": "object", + "additionalProperties": false, + "properties": { + "state": { + "type": "string", + "enum": ["disabled", "enabled"], + "description": "Metrics collection state" + } + } + }, + "MatchingRule": { + "type": "object", + "required": ["id", "attributes"], + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the rule" + }, + "targetPercentile": { + "$ref": "#/definitions/TargetPercentile" + }, + "attributes": { + "type": "object", + "description": "Matching attributes for the rule", + "properties": { + "locale": { "$ref": "#/definitions/SingleValueStringArrayAttribute" }, + "osApi": { "$ref": "#/definitions/StringRangeAttribute" }, + "formFactor": { "$ref": "#/definitions/SingleValueStringArrayAttribute" }, + "isInternalUser": { "$ref": "#/definitions/SingleValueBooleanAttribute" }, + "appId": { "$ref": "#/definitions/SingleValueStringAttribute" }, + "appVersion": { "$ref": "#/definitions/StringRangeAttribute" }, + "atb": { "$ref": "#/definitions/SingleValueStringAttribute" }, + "appAtb": { "$ref": "#/definitions/SingleValueStringAttribute" }, + "searchAtb": { "$ref": "#/definitions/SingleValueStringAttribute" }, + "expVariant": { "$ref": "#/definitions/SingleValueStringAttribute" }, + "emailEnabled": { "$ref": "#/definitions/SingleValueBooleanAttribute" }, + "bookmarks": { "$ref": "#/definitions/NumericRangeAttribute" }, + "favorites": { "$ref": "#/definitions/NumericRangeAttribute" }, + "appTheme": { "$ref": "#/definitions/SingleValueStringAttribute" }, + "daysSinceInstalled": { "$ref": "#/definitions/NumericRangeAttribute" }, + "daysSinceNetPEnabled": { "$ref": "#/definitions/NumericRangeAttribute" }, + "pproEligible": { "$ref": "#/definitions/SingleValueBooleanAttribute" }, + "pproSubscriber": { "$ref": "#/definitions/SingleValueBooleanAttribute" }, + "pproDaysSinceSubscribed": { "$ref": "#/definitions/NumericRangeAttribute" }, + "pproDaysUntilExpiryOrRenewal": { "$ref": "#/definitions/NumericRangeAttribute" }, + "pproPurchasePlatform": { "$ref": "#/definitions/SingleValueStringArrayAttribute" }, + "pproSubscriptionStatus": { "$ref": "#/definitions/SingleValueStringArrayAttribute" }, + "interactedWithMessage": { "$ref": "#/definitions/SingleValueStringArrayAttribute" }, + "interactedWithDeprecatedMacRemoteMessage": { "$ref": "#/definitions/SingleValueStringArrayAttribute" }, + "installedMacAppStore": { "$ref": "#/definitions/SingleValueBooleanAttribute" }, + "pinnedTabs": { "$ref": "#/definitions/NumericRangeAttribute" }, + "customHomePage": { "$ref": "#/definitions/SingleValueBooleanAttribute" }, + "duckPlayerOnboarded": { "$ref": "#/definitions/SingleValueBooleanAttribute" }, + "duckPlayerEnabled": { "$ref": "#/definitions/SingleValueBooleanAttribute" }, + "messageShown": { "$ref": "#/definitions/SingleValueStringArrayAttribute" }, + "isCurrentFreemiumPIRUser": { "$ref": "#/definitions/SingleValueBooleanAttribute" }, + "allFeatureFlagsEnabled": { "$ref": "#/definitions/SingleValueStringArrayAttribute" }, + "subscriptionFreeTrialActive": { "$ref": "#/definitions/SingleValueBooleanAttribute" }, + "daysSinceDuckAiUsed": { "$ref": "#/definitions/NumericRangeAttribute" } + }, + "additionalProperties": false + } + } + }, + "TargetPercentile": { + "type": "object", + "additionalProperties": false, + "properties": { + "before": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Target percentile threshold (0.0 to 1.0)" + } + } + }, + "SingleValueStringAttribute": { + "type": "object", + "description": "Single value matching for string attributes", + "additionalProperties": false, + "properties": { + "value": { + "type": "string", + "description": "Expected string value for matching" + }, + "fallback": { + "type": "boolean", + "description": "Whether to use this as a fallback rule" + } + } + }, + "SingleValueBooleanAttribute": { + "type": "object", + "description": "Single value matching for boolean attributes", + "additionalProperties": false, + "properties": { + "value": { + "type": "boolean", + "description": "Expected boolean value for matching" + }, + "fallback": { + "type": "boolean", + "description": "Whether to use this as a fallback rule" + } + } + }, + "SingleValueStringArrayAttribute": { + "type": "object", + "description": "Single value matching for string array attributes", + "additionalProperties": false, + "properties": { + "value": { + "type": "array", + "items": { "type": "string" }, + "description": "Expected array of string values for matching" + }, + "fallback": { + "type": "boolean", + "description": "Whether to use this as a fallback rule" + } + } + }, + "NumericRangeAttribute": { + "type": "object", + "description": "Numeric range matching for integer attributes", + "additionalProperties": false, + "properties": { + "min": { + "type": "integer", + "description": "Minimum value for range matching" + }, + "max": { + "type": "integer", + "description": "Maximum value for range matching" + }, + "value": { + "type": "integer", + "description": "Specific value for exact matching (overrides min/max)" + }, + "fallback": { + "type": "boolean", + "description": "Whether to use this as a fallback rule" + } + } + }, + "VersionString": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:\\.(0|[1-9]\\d*))?$", + "description": "Version string in format x.y.z or x.y.z.a where .a is optional (e.g., '1.0.0', '2.1.3.1')" + }, + "StringRangeAttribute": { + "type": "object", + "description": "String range matching for version attributes", + "additionalProperties": false, + "properties": { + "min": { + "$ref": "#/definitions/VersionString", + "description": "Minimum version for range matching" + }, + "max": { + "$ref": "#/definitions/VersionString", + "description": "Maximum version for range matching" + }, + "value": { + "$ref": "#/definitions/VersionString", + "description": "Specific version for exact matching (overrides min/max)" + }, + "fallback": { + "type": "boolean", + "description": "Whether to use this as a fallback rule" + } + } + } + } +}