diff --git a/.github/actions/backend-package-updater/action.yml b/.github/actions/backend-package-updater/action.yml new file mode 100644 index 00000000..4b9cf271 --- /dev/null +++ b/.github/actions/backend-package-updater/action.yml @@ -0,0 +1,109 @@ +name: 'Backend Package Updater' +description: 'Updates NPM packages in backend assets/ folders from JFrog registry' + +inputs: + branch: + description: 'Git branch name to determine dist tag' + required: true + actor: + description: 'GitHub actor who triggered the workflow' + required: true + dot-npmrc: + description: 'Content of .npmrc file for private registry authentication' + required: true + +outputs: + pr-number: + description: 'Pull request number (if created)' + value: ${{ steps.create-pr.outputs.pr-number }} + +runs: + using: 'composite' + steps: + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.15.0 + + - name: Install github-actions dependencies (without custom .npmrc) + shell: bash + run: | + cd "$GITHUB_ACTION_PATH/../../.." + echo "Installing dependencies in: $(pwd)" + npm ci + + - name: Configure .npmrc in backend repo root + shell: bash + run: | + cat > .npmrc << 'NPMRC_EOF' + ${{ inputs.dot-npmrc }} + NPMRC_EOF + echo "✓ .npmrc configured in backend repo in $(pwd)" + echo "Debug: .npmrc file contents (sensitive parts masked):" + cat .npmrc | sed 's/\(password\|_auth\|_authToken\)=.*/\1=***/g' + + - name: Test npm registry access + shell: bash + run: | + echo "Testing npm access with configured .npmrc..." + npm ping || echo "npm ping failed - checking npm config..." + npm config list + echo "" + echo "Testing npm access from subdirectory..." + cd cf.cplace.training.extended/assets || true + npm config list || true + + - name: Run package updater + shell: bash + env: + BRANCH: ${{ inputs.branch }} + ACTOR: ${{ inputs.actor }} + run: npx --yes ts-node "$GITHUB_ACTION_PATH/../../../tools/scripts/backend-package-updater/main.ts" + + - name: Check for changes + id: check-changes + shell: bash + run: | + if [[ -n $(git status --porcelain) ]]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + else + echo "has_changes=false" >> $GITHUB_OUTPUT + fi + + - name: Create Pull Request + id: create-pr + if: steps.check-changes.outputs.has_changes == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ACTOR: ${{ inputs.actor }} + BRANCH: ${{ inputs.branch }} + run: | + # Read PR description + PR_BODY=$(cat pr-description.md) + + # Create branch + BRANCH_NAME="automated/frontend-packages-${BRANCH//\//-}-$(date +%Y%m%d-%H%M%S)" + git checkout -b "$BRANCH_NAME" + + # Commit changes + git add . + git commit -m "Update frontend packages from $BRANCH branch" + + # Push branch + git push -u origin "$BRANCH_NAME" + + # Create PR with assignee, fallback to no assignee if permission denied + PR_OUTPUT=$(gh pr create \ + --title "Update frontend packages from $BRANCH" \ + --body "$PR_BODY" \ + --assignee "$ACTOR" 2>&1) || \ + PR_OUTPUT=$(gh pr create \ + --title "Update frontend packages from $BRANCH" \ + --body "$PR_BODY" 2>&1) + + # Extract PR number + PR_NUMBER=$(echo "$PR_OUTPUT" | grep -oP 'github.com/.+/pull/\K\d+' || echo "") + echo "pr-number=$PR_NUMBER" >> $GITHUB_OUTPUT + + echo "Created PR: $PR_OUTPUT" diff --git a/.github/workflow-templates/be/frontend-package-update.yml b/.github/workflow-templates/be/frontend-package-update.yml new file mode 100644 index 00000000..0fc2739e --- /dev/null +++ b/.github/workflow-templates/be/frontend-package-update.yml @@ -0,0 +1,33 @@ +name: Frontend Package Update + +on: + repository_dispatch: + types: [frontend-packages-published] + +jobs: + update-packages: + name: Update Frontend Packages + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.client_payload.branch }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config --global user.name "GitHub Actions Bot" + git config --global user.email "actions@github.com" + + + - name: Update packages + uses: collaborationFactory/github-actions/.github/actions/backend-package-updater@master + with: + branch: ${{ github.event.client_payload.branch }} + actor: ${{ github.event.client_payload.actor }} + dot-npmrc: ${{ secrets.DOT_NPMRC }} diff --git a/.github/workflow-templates/fe/fe-check-upmerge.yml b/.github/workflow-templates/fe/fe-check-upmerge.yml index 5004f76d..268230da 100644 --- a/.github/workflow-templates/fe/fe-check-upmerge.yml +++ b/.github/workflow-templates/fe/fe-check-upmerge.yml @@ -8,7 +8,7 @@ permissions: write-all jobs: check-upmerge: - uses: collaborationFactory/github-actions/.github/workflows/fe-check-upmerge.yml@master + uses: collaborationFactory/github-actions/.github/workflows/fe-check-upmerge.yml@feature/automate-frontend-pkg-integration secrets: SLACK_TOKEN_UPMERGE: ${{ secrets.SLACK_TOKEN_UPMERGE }} GIT_USER_EMAIL: ${{ secrets.GIT_USER_EMAIL }} diff --git a/.github/workflow-templates/fe/fe-cleanup-snapshots.yml b/.github/workflow-templates/fe/fe-cleanup-snapshots.yml index c163fc2c..9e2a7749 100644 --- a/.github/workflow-templates/fe/fe-cleanup-snapshots.yml +++ b/.github/workflow-templates/fe/fe-cleanup-snapshots.yml @@ -8,7 +8,7 @@ permissions: write-all jobs: cleanup-snapshots: - uses: collaborationFactory/github-actions/.github/workflows/fe-cleanup-snapshots.yml@master + uses: collaborationFactory/github-actions/.github/workflows/fe-cleanup-snapshots.yml@feature/automate-frontend-pkg-integration secrets: JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} JFROG_URL: ${{ secrets.JFROG_URL }} diff --git a/.github/workflow-templates/fe/fe-licenses.yml b/.github/workflow-templates/fe/fe-licenses.yml index c4404d99..89a88d11 100644 --- a/.github/workflow-templates/fe/fe-licenses.yml +++ b/.github/workflow-templates/fe/fe-licenses.yml @@ -13,7 +13,7 @@ env: jobs: check-licenses: - uses: collaborationFactory/github-actions/.github/workflows/fe-licenses.yml@master + uses: collaborationFactory/github-actions/.github/workflows/fe-licenses.yml@feature/automate-frontend-pkg-integration with: FOSS_DIST: dist CUSTOM_LICENSES_JSON: custom-licenses/custom-licenses.json diff --git a/.github/workflow-templates/fe/fe-main.yml b/.github/workflow-templates/fe/fe-main.yml index af7ffffc..9e74b382 100644 --- a/.github/workflow-templates/fe/fe-main.yml +++ b/.github/workflow-templates/fe/fe-main.yml @@ -13,7 +13,7 @@ env: jobs: install-deps: - uses: collaborationFactory/github-actions/.github/workflows/fe-install-deps.yml@master + uses: collaborationFactory/github-actions/.github/workflows/fe-install-deps.yml@feature/automate-frontend-pkg-integration with: GHA_REF: '' secrets: @@ -22,7 +22,7 @@ jobs: # This job is only needed in case you are using Percy e2e-tests: needs: install-deps - uses: collaborationFactory/github-actions/.github/workflows/fe-e2e.yml@master + uses: collaborationFactory/github-actions/.github/workflows/fe-e2e.yml@feature/automate-frontend-pkg-integration with: GHA_REF: '' GHA_BASE: ${{ github.event.before }} @@ -31,14 +31,14 @@ jobs: build: needs: install-deps - uses: collaborationFactory/github-actions/.github/workflows/fe-build.yml@master + uses: collaborationFactory/github-actions/.github/workflows/fe-build.yml@feature/automate-frontend-pkg-integration with: GHA_REF: '' GHA_BASE: ${{ github.event.before }} snapshot: needs: build - uses: collaborationFactory/github-actions/.github/workflows/fe-snapshot.yml@master + uses: collaborationFactory/github-actions/.github/workflows/fe-snapshot.yml@feature/automate-frontend-pkg-integration with: GHA_REF: '' GHA_BASE: ${{ github.event.before }} diff --git a/.github/workflow-templates/fe/fe-pr-close.yml b/.github/workflow-templates/fe/fe-pr-close.yml index c90a6248..050523d9 100644 --- a/.github/workflow-templates/fe/fe-pr-close.yml +++ b/.github/workflow-templates/fe/fe-pr-close.yml @@ -9,7 +9,7 @@ permissions: write-all jobs: remove-artifacts: - uses: collaborationFactory/github-actions/.github/workflows/fe-pr-close.yml@master + uses: collaborationFactory/github-actions/.github/workflows/fe-pr-close.yml@feature/automate-frontend-pkg-integration with: GHA_BASE: ${{ github.event.pull_request.base.ref }} secrets: diff --git a/.github/workflow-templates/fe/fe-pr-snapshot.yml b/.github/workflow-templates/fe/fe-pr-snapshot.yml index d14fd3b3..b08e0a57 100644 --- a/.github/workflow-templates/fe/fe-pr-snapshot.yml +++ b/.github/workflow-templates/fe/fe-pr-snapshot.yml @@ -9,7 +9,7 @@ permissions: write-all jobs: publish-pr-snapshot: - uses: collaborationFactory/github-actions/.github/workflows/fe-pr-snapshot.yml@master + uses: collaborationFactory/github-actions/.github/workflows/fe-pr-snapshot.yml@feature/automate-frontend-pkg-integration with: GHA_BASE: ${{ github.event.pull_request.base.ref }} secrets: diff --git a/.github/workflow-templates/fe/fe-pr.yml b/.github/workflow-templates/fe/fe-pr.yml index 5e447a09..7d00e8fc 100644 --- a/.github/workflow-templates/fe/fe-pr.yml +++ b/.github/workflow-templates/fe/fe-pr.yml @@ -12,7 +12,7 @@ env: jobs: install-deps: - uses: collaborationFactory/github-actions/.github/workflows/fe-install-deps.yml@master + uses: collaborationFactory/github-actions/.github/workflows/fe-install-deps.yml@feature/automate-frontend-pkg-integration with: GHA_REF: ${{ github.event.pull_request.head.ref }} secrets: @@ -20,20 +20,20 @@ jobs: build: needs: install-deps - uses: collaborationFactory/github-actions/.github/workflows/fe-build.yml@master + uses: collaborationFactory/github-actions/.github/workflows/fe-build.yml@feature/automate-frontend-pkg-integration with: GHA_REF: ${{ github.event.pull_request.head.ref }} GHA_BASE: ${{ github.event.pull_request.base.ref }} code-quality: needs: install-deps - uses: collaborationFactory/github-actions/.github/workflows/fe-code-quality.yml@master + uses: collaborationFactory/github-actions/.github/workflows/fe-code-quality.yml@feature/automate-frontend-pkg-integration with: GHA_REF: ${{ github.event.pull_request.head.ref }} GHA_BASE: ${{ github.event.pull_request.base.ref }} e2e: needs: install-deps - uses: collaborationFactory/github-actions/.github/workflows/fe-e2e.yml@master + uses: collaborationFactory/github-actions/.github/workflows/fe-e2e.yml@feature/automate-frontend-pkg-integration with: GHA_REF: ${{ github.event.pull_request.head.ref }} GHA_BASE: ${{ github.event.pull_request.base.ref }} diff --git a/.github/workflow-templates/fe/fe-release.yml b/.github/workflow-templates/fe/fe-release.yml index aeb25b4e..83fb5bfe 100644 --- a/.github/workflow-templates/fe/fe-release.yml +++ b/.github/workflow-templates/fe/fe-release.yml @@ -12,7 +12,7 @@ env: jobs: install-deps: - uses: collaborationFactory/github-actions/.github/workflows/fe-install-deps.yml@master + uses: collaborationFactory/github-actions/.github/workflows/fe-install-deps.yml@feature/automate-frontend-pkg-integration with: GHA_REF: '' secrets: @@ -20,7 +20,7 @@ jobs: e2e-tests: needs: install-deps - uses: collaborationFactory/github-actions/.github/workflows/fe-e2e.yml@master + uses: collaborationFactory/github-actions/.github/workflows/fe-e2e.yml@feature/automate-frontend-pkg-integration with: GHA_REF: '' GHA_BASE: ${{ github.event.before }} @@ -29,14 +29,14 @@ jobs: build: needs: install-deps - uses: collaborationFactory/github-actions/.github/workflows/fe-build.yml@master + uses: collaborationFactory/github-actions/.github/workflows/fe-build.yml@feature/automate-frontend-pkg-integration with: GHA_REF: '' GHA_BASE: ${{ github.event.before }} tag: needs: [build, e2e-tests] - uses: collaborationFactory/github-actions/.github/workflows/fe-tag.yml@master + uses: collaborationFactory/github-actions/.github/workflows/fe-tag.yml@feature/automate-frontend-pkg-integration with: GHA_REF: '' GHA_BASE: ${{ github.event.before }} diff --git a/.github/workflow-templates/fe/fe-tag-pushed.yml b/.github/workflow-templates/fe/fe-tag-pushed.yml index 8c0e476d..c1069337 100644 --- a/.github/workflow-templates/fe/fe-tag-pushed.yml +++ b/.github/workflow-templates/fe/fe-tag-pushed.yml @@ -12,7 +12,7 @@ env: jobs: release-version: - uses: collaborationFactory/github-actions/.github/workflows/fe-release.yml@master + uses: collaborationFactory/github-actions/.github/workflows/fe-release.yml@feature/automate-frontend-pkg-integration secrets: JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} JFROG_URL: ${{ secrets.JFROG_URL }} diff --git a/.github/workflows/be-package-update.yml b/.github/workflows/be-package-update.yml new file mode 100644 index 00000000..f020fb2e --- /dev/null +++ b/.github/workflows/be-package-update.yml @@ -0,0 +1,31 @@ +name: Backend Package Update + +on: + repository_dispatch: + types: [frontend-packages-published] + secrets: + DOT_NPMRC: + required: true + +jobs: + update-packages: + name: Update Frontend Packages + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.client_payload.branch }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config --global user.name "GitHub Actions Bot" + git config --global user.email "actions@github.com" + + - name: Update packages + uses: collaborationFactory/github-actions/.github/actions/backend-package-updater@feature/automate-frontend-pkg-integration + with: + branch: ${{ github.event.client_payload.branch }} + actor: ${{ github.event.client_payload.actor }} diff --git a/.github/workflows/fe-build.yml b/.github/workflows/fe-build.yml index d4116b62..f4fbd3d5 100644 --- a/.github/workflows/fe-build.yml +++ b/.github/workflows/fe-build.yml @@ -44,7 +44,7 @@ jobs: run: git fetch origin ${{ inputs.GHA_BASE }}:${{ inputs.GHA_BASE }} || true - name: Build and Storybook - uses: collaborationFactory/github-actions/.github/actions/run-many@master + uses: collaborationFactory/github-actions/.github/actions/run-many@feature/automate-frontend-pkg-integration with: target: ${{ matrix.target }} jobIndex: ${{ matrix.jobIndex }} diff --git a/.github/workflows/fe-check-upmerge.yml b/.github/workflows/fe-check-upmerge.yml index 9426c276..8767a507 100644 --- a/.github/workflows/fe-check-upmerge.yml +++ b/.github/workflows/fe-check-upmerge.yml @@ -32,7 +32,7 @@ jobs: key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }} - name: check if upmerge is necessary and post to slack - uses: collaborationFactory/github-actions/.github/actions/upmerge@master + uses: collaborationFactory/github-actions/.github/actions/upmerge@feature/automate-frontend-pkg-integration env: SLACK_TOKEN_UPMERGE: ${{ secrets.SLACK_TOKEN_UPMERGE }} GIT_USER_EMAIL: ${{ secrets.GIT_USER_EMAIL }} diff --git a/.github/workflows/fe-cleanup-snapshots.yml b/.github/workflows/fe-cleanup-snapshots.yml index c37db0fb..e52a5efa 100644 --- a/.github/workflows/fe-cleanup-snapshots.yml +++ b/.github/workflows/fe-cleanup-snapshots.yml @@ -39,7 +39,7 @@ jobs: dot-npmrc: ${{ secrets.DOT_NPMRC }} - name: Cleanup Snapshot Artifacts - uses: collaborationFactory/github-actions/.github/actions/snapshots@master + uses: collaborationFactory/github-actions/.github/actions/snapshots@feature/automate-frontend-pkg-integration env: JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} JFROG_URL: ${{ secrets.JFROG_URL }} diff --git a/.github/workflows/fe-code-quality.yml b/.github/workflows/fe-code-quality.yml index 7c3d2eeb..0bf78e49 100644 --- a/.github/workflows/fe-code-quality.yml +++ b/.github/workflows/fe-code-quality.yml @@ -10,25 +10,25 @@ on: required: false description: "SonarCloud organization key, e.g., 'my-org'" type: string - default: "collaborationfactory" + default: 'collaborationfactory' SONAR_CLOUD_PROJECT_KEY: required: false description: "SonarCloud project key, e.g., 'my-project'" type: string - default: "" + default: '' SONAR_PROPERTIES: required: false - description: "Additional sonar-project.properties content" + description: 'Additional sonar-project.properties content' type: string - default: "" + default: '' jobs: code-quality: runs-on: ubuntu-latest strategy: matrix: - target: [ 'test' ] - jobIndex: [ 1, 2, 3,4 ] + target: ['test'] + jobIndex: [1, 2, 3, 4] fail-fast: false # Continue running all matrix combinations even if one fails env: jobCount: 4 @@ -60,7 +60,7 @@ jobs: - name: Unit Tests id: test - uses: collaborationFactory/github-actions/.github/actions/run-many@master + uses: collaborationFactory/github-actions/.github/actions/run-many@feature/automate-frontend-pkg-integration continue-on-error: true with: target: ${{ matrix.target }} diff --git a/.github/workflows/fe-e2e.yml b/.github/workflows/fe-e2e.yml index f3513d70..2e735504 100644 --- a/.github/workflows/fe-e2e.yml +++ b/.github/workflows/fe-e2e.yml @@ -48,7 +48,7 @@ jobs: - name: Affected Regression Tests id: regressionTests continue-on-error: true - uses: collaborationFactory/github-actions/.github/actions/run-many@master + uses: collaborationFactory/github-actions/.github/actions/run-many@feature/automate-frontend-pkg-integration with: target: ${{ matrix.target }} jobIndex: ${{ matrix.jobIndex }} diff --git a/.github/workflows/fe-pr-close.yml b/.github/workflows/fe-pr-close.yml index 05f7c2ae..9bb9eeba 100644 --- a/.github/workflows/fe-pr-close.yml +++ b/.github/workflows/fe-pr-close.yml @@ -45,7 +45,7 @@ jobs: run: npm ci - name: Delete Snapshots from NPM Registry - uses: collaborationFactory/github-actions/.github/actions/artifacts@master + uses: collaborationFactory/github-actions/.github/actions/artifacts@feature/automate-frontend-pkg-integration env: JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} JFROG_URL: ${{ secrets.JFROG_URL }} diff --git a/.github/workflows/fe-pr-snapshot.yml b/.github/workflows/fe-pr-snapshot.yml index 9d40c315..d81b910c 100644 --- a/.github/workflows/fe-pr-snapshot.yml +++ b/.github/workflows/fe-pr-snapshot.yml @@ -62,7 +62,7 @@ jobs: run: npm ci - name: Build and Push to Jfrog NPM Registry - uses: collaborationFactory/github-actions/.github/actions/artifacts@master + uses: collaborationFactory/github-actions/.github/actions/artifacts@feature/automate-frontend-pkg-integration env: JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} JFROG_URL: ${{ secrets.JFROG_URL }} @@ -78,3 +78,16 @@ jobs: file-path: githubCommentsForPR.txt comment-tag: published-artifacts mode: upsert + + - name: Trigger Backend Package Update + if: inputs.BACKEND_REPO != '' + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.CROSS_REPO_PAT }} + repository: collaborationFactory/${{ inputs.BACKEND_REPO }} + event-type: frontend-packages-published + client-payload: | + { + "branch": "${{ github.ref_name }}", + "actor": "${{ github.actor }}" + } diff --git a/.github/workflows/fe-release.yml b/.github/workflows/fe-release.yml index 324070bd..41589a09 100644 --- a/.github/workflows/fe-release.yml +++ b/.github/workflows/fe-release.yml @@ -2,6 +2,11 @@ name: Frontend Release Workflow on: workflow_call: + inputs: + BACKEND_REPO: + type: string + required: false + description: 'Backend repository name to trigger' secrets: JFROG_BASE64_TOKEN: required: true @@ -11,6 +16,8 @@ on: required: true DOT_NPMRC: required: true + CROSS_REPO_PAT: + required: false env: NX_BRANCH: ${{ github.event.number }} @@ -50,9 +57,22 @@ jobs: uses: dawidd6/action-get-tag@v1 - name: Build and Push to Jfrog NPM Registry - uses: collaborationFactory/github-actions/.github/actions/artifacts@master + uses: collaborationFactory/github-actions/.github/actions/artifacts@feature/automate-frontend-pkg-integration env: JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} JFROG_URL: ${{ secrets.JFROG_URL }} JFROG_USER: ${{ secrets.JFROG_USER }} TAG: ${{ steps.tag.outputs.tag }} + + - name: Trigger Backend Package Update + if: inputs.BACKEND_REPO != '' + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.CROSS_REPO_PAT }} + repository: collaborationFactory/${{ inputs.BACKEND_REPO }} + event-type: frontend-packages-published + client-payload: | + { + "branch": "${{ github.ref_name }}", + "actor": "${{ github.actor }}" + } diff --git a/.github/workflows/fe-snapshot.yml b/.github/workflows/fe-snapshot.yml index c87e0227..1fee3e23 100644 --- a/.github/workflows/fe-snapshot.yml +++ b/.github/workflows/fe-snapshot.yml @@ -9,6 +9,10 @@ on: GHA_BASE: type: string required: true + BACKEND_REPO: + type: string + required: false + description: 'Backend repository name to trigger (e.g., "main" or "cplace-paw")' secrets: JFROG_BASE64_TOKEN: required: true @@ -18,6 +22,8 @@ on: required: true GIT_USER_TOKEN: required: false + CROSS_REPO_PAT: + required: false jobs: snapshot: @@ -38,10 +44,23 @@ jobs: key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }} - name: Build and Push to Jfrog NPM Registry - uses: collaborationFactory/github-actions/.github/actions/artifacts@master + uses: collaborationFactory/github-actions/.github/actions/artifacts@feature/automate-frontend-pkg-integration env: JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} JFROG_URL: ${{ secrets.JFROG_URL }} JFROG_USER: ${{ secrets.JFROG_USER }} BASE: ${{ inputs.GHA_BASE }} SNAPSHOT: true + + - name: Trigger Backend Package Update + if: inputs.BACKEND_REPO != '' + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.CROSS_REPO_PAT }} + repository: collaborationFactory/${{ inputs.BACKEND_REPO }} + event-type: frontend-packages-published + client-payload: | + { + "branch": "${{ github.ref_name }}", + "actor": "${{ github.actor }}" + } diff --git a/.github/workflows/fe-sonar.yml b/.github/workflows/fe-sonar.yml index 876a2464..85d28dc5 100644 --- a/.github/workflows/fe-sonar.yml +++ b/.github/workflows/fe-sonar.yml @@ -6,18 +6,18 @@ on: secrets: SONAR_CLOUD_TOKEN: required: true - description: "SonarCloud authentication token" + description: 'SonarCloud authentication token' inputs: SONAR_CLOUD_ORG: required: false description: "SonarCloud organization key, e.g., 'my-org'" type: string - default: "collaborationfactory" + default: 'collaborationfactory' SONAR_PROPERTIES: required: false - description: "Additional sonar-project.properties content" + description: 'Additional sonar-project.properties content' type: string - default: "" + default: '' jobs: sonarqube-scan: @@ -31,7 +31,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v5 with: - fetch-depth: 0 # Full history for SonarQube analysis + fetch-depth: 0 # Full history for SonarQube analysis - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/fe-tag.yml b/.github/workflows/fe-tag.yml index 1aa8fe4c..9b69b17c 100644 --- a/.github/workflows/fe-tag.yml +++ b/.github/workflows/fe-tag.yml @@ -48,7 +48,7 @@ jobs: git config user.email ${{ secrets.GIT_USER_EMAIL }} - name: Bump Version and Push new Tag - uses: collaborationFactory/github-actions/.github/actions/artifacts@master + uses: collaborationFactory/github-actions/.github/actions/artifacts@feature/automate-frontend-pkg-integration env: JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} JFROG_URL: ${{ secrets.JFROG_URL }} diff --git a/.gitignore b/.gitignore index 24ae7c7c..376dd6c2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .idea **/node_modules /githubCommentsForPR.txt +dist/ # claude ai diff --git a/thoughts/shared/designs/backend-frontend-package-automation.md b/thoughts/shared/designs/backend-frontend-package-automation.md new file mode 100644 index 00000000..5ae8a686 --- /dev/null +++ b/thoughts/shared/designs/backend-frontend-package-automation.md @@ -0,0 +1,1031 @@ +# Backend Frontend Package Automation - Design Approach + +## Overview + +Automate the process of installing/updating frontend NPM packages in backend repositories after they are published to JFrog NPM Registry. This eliminates manual coordination between frontend and backend teams and ensures backend repositories stay in sync with published frontend packages. + +## Problem Statement + +Currently, when frontend packages are published to JFrog NPM Registry (via `fe-snapshot.yml` or `fe-release.yml` workflows), backend repositories must manually update their `**/assets/package.json` files to consume the new versions. This manual process: + +- Creates delays between frontend publishing and backend integration +- Requires coordination between frontend and backend teams +- Is error-prone (forgetting to update dependencies) +- Lacks visibility into what needs updating + +### Requirements + +1. **Automated Triggering**: Backend package updates triggered automatically after successful frontend publishing +2. **Branch Awareness**: Updates must happen on the same branch as the frontend workflow (e.g., `release/25.4`) +3. **Dist Tag Resolution**: Use appropriate NPM dist tags based on branch: + - `main`/`master` branches → `snapshot` dist tag + - `release/*` branches → `release-{major}.{minor}` dist tag (e.g., `release-25.4`) +4. **Multi-Plugin Support**: Scan and update all `**/assets/package.json` files in backend repository +5. **Scope Filtering**: Only update packages from relevant NPM scopes (e.g., `@cplace-next`, `@cplace-paw`) +6. **Single Consolidated PR**: Create one PR per backend repo with all package updates +7. **Proper Assignment**: Assign PR to the person who triggered the frontend workflow, or create without assignee if they lack access +8. **Reusable Workflow**: Provide a workflow in this repository that backend repos can include + +### Constraints + +- Multiple `**/assets` folders may exist in backend repos (one per plugin) +- Must work with JFrog Artifactory NPM registry +- Must respect existing authentication mechanisms +- Backend repos may have different directory structures +- Must handle permission errors gracefully +- Should provide clear visibility into what was updated and what failed + +## Design Dimensions & Decisions + +### 1. Cross-Repository Communication Pattern + +**Chosen Approach:** `repository_dispatch` with Organization PAT + +**Rationale:** +GitHub's `repository_dispatch` event is the standard mechanism for cross-repository workflow triggering. Using an organization-level PAT eliminates the need to configure tokens in each frontend repository individually. + +**Alternatives Considered:** + +- **GitHub App with Installation Token**: Rejected because it requires creating and maintaining a GitHub App, adding unnecessary complexity for this straightforward use case +- **External Webhook Service**: Rejected because it requires external infrastructure and still needs PAT/GitHub App, adding deployment and maintenance overhead +- **Manual `workflow_dispatch` Trigger**: Rejected because it defeats the purpose of automation and reintroduces manual coordination +- **Scheduled Polling**: Rejected because it introduces delays (up to polling interval) and runs unnecessarily when no updates are available + +**Implications:** + +- Requires creating an organization-level PAT (one-time setup) +- PAT must have `repo` scope for triggering workflows +- PAT should be stored as organization secret (e.g., `CROSS_REPO_PAT`) +- Token maintenance required (expiration monitoring, rotation) +- Frontend workflows need to accept `BACKEND_REPO` input parameter +- Dispatch payload will contain: `branch` (branch name) and `actor` (triggering user) + +--- + +### 2. Package Discovery & Update Strategy + +**Chosen Approach:** Glob Pattern Search + Centralized Update Script (TypeScript) + +**Rationale:** +A centralized TypeScript script provides the best balance of robustness, maintainability, and user experience. It allows consistent version updates across all assets folders, generates detailed change summaries for PR descriptions, and follows existing codebase patterns (similar to `tools/scripts/artifacts/`). + +**Alternatives Considered:** + +- **Glob Pattern + In-Place Shell Updates**: Rejected because it would run npm update multiple times (once per folder), potentially leading to inconsistent versions and making it difficult to generate a summary of changes +- **NX-Style Affected Project Detection**: Rejected because backend repos may not use NX, and this would require backend-specific configuration +- **GitHub Action Marketplace Solution**: Rejected because existing actions don't support the `**/assets/package.json` pattern, scope filtering, or JFrog registry authentication + +**Implications:** + +- TypeScript script location: `tools/scripts/backend-package-updater/` +- Script will use glob patterns to find all `**/assets/package.json` files +- Script queries npm registry for latest versions matching dist tags +- Script updates all package.json files in a single pass +- Script runs `npm install` in each assets directory (using `cwd: assetsDir`) +- Script generates detailed change summary for PR description +- Follows existing patterns from `tools/scripts/artifacts/` structure +- Requires Node.js dependencies: glob, fast-glob or similar + +--- + +### 3. NPM Dist Tag Resolution + +**Chosen Approach:** Simple Branch Name Mapping + +**Rationale:** +Simple, explicit branch-to-tag mapping is sufficient for the known branch patterns and makes behavior predictable and easy to debug. The fail-fast approach with clear error messages helps catch unsupported branch patterns early. + +**Alternatives Considered:** + +- **Configurable Branch-to-Tag Mapping**: Rejected because it adds unnecessary complexity for handling only two branch patterns, and configuration overhead outweighs benefits +- **Query Registry for Available Tags**: Rejected because extra API calls slow down execution and are unnecessary if we trust that frontend publishing succeeded +- **Smart Fallback with Validation**: Rejected because silent fallbacks could hide configuration issues, making debugging harder + +**Mapping Logic:** + +```typescript +function getDistTag(branchName: string): string { + if (branchName === 'main' || branchName === 'master') { + return 'snapshot'; + } + + if (branchName.startsWith('release/')) { + // Extract version: release/25.4 → release-25.4 + const version = branchName.replace('release/', 'release-'); + return version; + } + + throw new Error( + `Unsupported branch pattern: ${branchName}. Supported: main, master, release/*` + ); +} +``` + +**Implications:** + +- Only `main`, `master`, and `release/*` branches are supported +- Workflow will fail fast with clear error for unexpected branch names +- No configuration needed per repository +- Easy to extend in the future if new branch patterns emerge +- Examples: + - `main` → `@cplace-next/cf-shell@snapshot` + - `master` → `@cplace-next/cf-shell@snapshot` + - `release/25.4` → `@cplace-next/cf-shell@release-25.4` + +--- + +### 4. PR Creation & Assignment Strategy + +**Chosen Approach:** GitHub CLI (`gh`) with Try-Catch Assignment + +**Rationale:** +Using `gh pr create` with built-in fallback via `||` operator provides the simplest implementation while ensuring PRs are always created. If assignment fails due to permissions, the command automatically retries without the assignee parameter. + +**Alternatives Considered:** + +- **Pre-Check for Collaborator Access**: Rejected because it requires an extra API call and adds complexity without significant benefit +- **GitHub API via TypeScript (Octokit)**: Rejected because bash with GitHub CLI is simpler and sufficient for this use case, avoiding additional dependencies +- **Fallback with @mention Comment**: Rejected because mentions may not notify users without repository access, and it adds an extra API call + +**Implementation:** + +```bash +gh pr create \ + --title "chore: Update frontend packages from $BRANCH" \ + --body "$PR_BODY" \ + --head "$UPDATE_BRANCH" \ + --base "$BRANCH" \ + --assignee "$ACTOR" || \ +gh pr create \ + --title "chore: Update frontend packages from $BRANCH" \ + --body "$PR_BODY" \ + --head "$UPDATE_BRANCH" \ + --base "$BRANCH" +``` + +**Implications:** + +- Command may run twice if assignment fails (once with assignee, once without) +- Failed assignment attempt will appear in logs (acceptable noise) +- PR always gets created successfully +- Uses `GITHUB_TOKEN` for PR creation (has write access in target repo) +- Title format: `chore: Update frontend packages from {branch}` +- PR body will contain detailed summary from TypeScript script + +--- + +### 5. Scope Filtering Mechanism + +**Chosen Approach:** Auto-Detect from package.json + +**Rationale:** +Automatic scope detection from existing dependencies eliminates configuration overhead while naturally limiting updates to only packages already in use. This is the most maintainable approach as it automatically adapts to the backend repository's actual dependencies. + +**Alternatives Considered:** + +- **Workflow Input Parameter (Array)**: Rejected because it requires configuration in each backend repo and isn't available from `repository_dispatch` events +- **Config File in Backend Repository**: Rejected because it requires creating and maintaining a config file in each backend repo, adding setup overhead +- **Hardcoded in Workflow**: Rejected because different backend repos may use different scopes, and changes would require workflow updates +- **Hybrid Config + Auto-Detect**: Rejected because two code paths add unnecessary complexity for marginal benefit + +**Algorithm:** + +```typescript +// 1. Find all **/assets/package.json files +const packageJsonPaths = glob.sync('**/assets/package.json'); + +// 2. Extract unique scopes from existing dependencies +const existingScopes = new Set(); +for (const pkgPath of packageJsonPaths) { + const pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const deps = { ...pkgJson.dependencies, ...pkgJson.devDependencies }; + + for (const dep of Object.keys(deps)) { + if (dep.startsWith('@')) { + const scope = dep.split('/')[0]; // @cplace-next/pkg → @cplace-next + existingScopes.add(scope); + } + } +} + +// 3. Update packages from detected scopes +for (const pkgPath of packageJsonPaths) { + const assetsDir = path.dirname(pkgPath); + + for (const scope of existingScopes) { + execSync( + `npm install ${scope}/*@${distTag}`, + { cwd: assetsDir } // Run in **/assets folder + ); + } +} +``` + +**Implications:** + +- Zero configuration required in backend repositories +- Only updates packages from scopes already present in package.json +- New scopes require manual first addition (one-time) +- Cannot accidentally update unexpected scopes +- Works naturally with multi-tenant backend repos (different plugins may use different scopes) +- npm commands execute in each `**/assets` directory where package.json lives + +--- + +### 6. Error Handling & Resilience + +**Chosen Approach:** Continue on Error with Summary Report + +**Rationale:** +Continuing through errors while collecting results provides the best resilience. If one plugin's assets folder has an issue, other plugins can still receive updates. The detailed summary in the PR description provides clear visibility into what succeeded and what failed. + +**Alternatives Considered:** + +- **Fail Fast - Stop on First Error**: Rejected because one problematic assets folder would block updates to all other folders, reducing overall system reliability +- **Transaction-Style with Rollback**: Rejected because rollback complexity (git operations) is overkill, and partial updates are acceptable given clear reporting +- **Fail Fast with Detailed Error Context**: Rejected because it still blocks all updates on first failure, though error messaging improvement is valuable and will be incorporated + +**Implementation:** + +```typescript +const results: UpdateResult[] = []; + +for (const assetsDir of assetsDirs) { + try { + const updates = await updatePackages(assetsDir, scopes, distTag); + results.push({ + path: assetsDir, + success: true, + updates, // array of {package, oldVersion, newVersion} + }); + } catch (error) { + results.push({ + path: assetsDir, + success: false, + error: error.message, + }); + } +} + +// Report results +const successful = results.filter((r) => r.success); +const failed = results.filter((r) => !r.success); + +// Only fail workflow if ALL updates failed +if (failed.length > 0 && successful.length === 0) { + throw new Error('All package updates failed'); +} + +// Generate PR description with detailed summary +return generatePRDescription(results, branch, distTag, actor); +``` + +**Implications:** + +- PRs may contain partial updates (some folders updated, some not) +- Workflow succeeds if at least one folder updates successfully +- Workflow fails only if all folders fail to update +- PR description clearly shows success/failure for each assets folder +- Failed folders require manual investigation and remediation +- Successful folders get updates immediately without waiting for fixes + +**PR Description Format:** + +```markdown +## Frontend Package Updates + +**Branch:** release/25.4 +**Dist Tag:** release-25.4 +**Triggered by:** @username + +### ✅ Successfully Updated (8/10) + +#### plugins/plugin-a/assets + +- @cplace-next/cf-shell: 25.3.0 → 25.4.0 +- @cplace-next/platform: 25.3.1 → 25.4.1 + +#### plugins/plugin-b/assets + +- @cplace-paw/components: 1.2.3 → 1.3.0 + +### ❌ Failed to Update (2/10) + +#### plugins/plugin-x/assets + +**Error:** Package @cplace-next/missing@release-25.4 not found in registry + +#### plugins/plugin-y/assets + +**Error:** Invalid package.json format: Unexpected token in JSON + +--- + +**Auto-detected scopes:** @cplace-next, @cplace-paw +``` + +--- + +## Overall Architecture + +### High-Level Flow + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Frontend Repository (cplace-fe, cplace-paw-fe) │ +│ │ +│ 1. Developer pushes to main/master or release/* branch │ +│ 2. fe-snapshot.yml or fe-release.yml workflow triggered │ +│ 3. Workflow builds and publishes packages to JFrog NPM │ +│ 4. On success: repository_dispatch to backend repo │ +│ Payload: { branch: "release/25.4", actor: "john" } │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ repository_dispatch event + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Backend Repository (main, cplace-paw) │ +│ │ +│ 1. Receives repository_dispatch event │ +│ 2. Calls reusable workflow from github-actions repo: │ +│ collaborationFactory/github-actions/ │ +│ .github/workflows/be-package-update.yml@master │ +│ 3. Workflow extracts: branch, actor from event payload │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ workflow_call + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ github-actions Repository │ +│ .github/workflows/be-package-update.yml │ +│ │ +│ 1. Checkout backend repo on specified branch │ +│ 2. Setup Node.js environment │ +│ 3. Configure JFrog NPM registry authentication │ +│ 4. Execute TypeScript update script │ +│ 5. Commit changes to new branch │ +│ 6. Create PR with changes │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ execute script + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ tools/scripts/backend-package-updater/main.ts │ +│ │ +│ 1. Scan: Find all **/assets/package.json files │ +│ 2. Detect: Extract NPM scopes from dependencies │ +│ 3. Resolve: Determine dist tag from branch name │ +│ 4. Update: Install packages with correct dist tag │ +│ - For each assets folder: │ +│ cd {assets-dir} │ +│ npm install @scope/*@{distTag} │ +│ 5. Collect: Gather results (successes and failures) │ +│ 6. Report: Generate PR description with summary │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ return results + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ PR Created in Backend Repository │ +│ │ +│ Title: "chore: Update frontend packages from release/25.4" │ +│ Assignee: @john (or none if no permission) │ +│ Body: Detailed summary with all changes and errors │ +│ Files: Multiple **/assets/package.json + package-lock.json │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Key Components + +#### 1. Modified Frontend Workflows + +**File:** `.github/workflows/fe-snapshot.yml` + +**Changes:** + +- Add input parameter `BACKEND_REPO` (optional, string) +- Add new job/step after successful publishing: + ```yaml + - name: Trigger Backend Package Update + if: inputs.BACKEND_REPO != '' + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.CROSS_REPO_PAT }} + repository: collaborationFactory/${{ inputs.BACKEND_REPO }} + event-type: frontend-packages-published + client-payload: | + { + "branch": "${{ github.ref_name }}", + "actor": "${{ github.actor }}" + } + ``` + +**File:** `.github/workflows/fe-release.yml` + +**Changes:** Same as `fe-snapshot.yml` above + +#### 2. New Backend Update Workflow + +**File:** `.github/workflows/be-package-update.yml` (new) + +**Purpose:** Reusable workflow that backend repositories call to update frontend packages + +**Triggers:** + +- `workflow_call`: Called by other workflows +- `repository_dispatch`: Triggered by frontend workflows + +**Structure:** + +```yaml +name: Backend Package Update + +on: + repository_dispatch: + types: [frontend-packages-published] + workflow_call: + inputs: + branch: + type: string + required: true + actor: + type: string + required: true + +jobs: + update-packages: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.client_payload.branch || inputs.branch }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 22.15.0 + + - name: Configure NPM Registry + # Setup .npmrc with JFrog credentials + + - name: Update Frontend Packages + uses: ./.github/actions/backend-package-updater + # Or directly call TypeScript script + + - name: Commit Changes + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "chore/update-frontend-packages-${{ github.run_id }}" + git add . + git commit -m "chore: Update frontend packages" + git push origin HEAD + + - name: Create Pull Request + env: + ACTOR: ${{ github.event.client_payload.actor || inputs.actor }} + BRANCH: ${{ github.event.client_payload.branch || inputs.branch }} + run: | + gh pr create \ + --title "chore: Update frontend packages from $BRANCH" \ + --body-file pr-description.md \ + --head "chore/update-frontend-packages-${{ github.run_id }}" \ + --base "$BRANCH" \ + --assignee "$ACTOR" || \ + gh pr create \ + --title "chore: Update frontend packages from $BRANCH" \ + --body-file pr-description.md \ + --head "chore/update-frontend-packages-${{ github.run_id }}" \ + --base "$BRANCH" +``` + +#### 3. TypeScript Update Script + +**Directory Structure:** + +``` +tools/scripts/backend-package-updater/ +├── main.ts # Entry point +├── updater.ts # Core update logic +├── utils.ts # Utility functions +└── types.ts # TypeScript type definitions +``` + +**File:** `tools/scripts/backend-package-updater/main.ts` + +```typescript +import { updateBackendPackages } from './updater'; + +async function main() { + try { + const branch = process.env.BRANCH || ''; + const actor = process.env.ACTOR || ''; + + const result = await updateBackendPackages(branch); + + // Write PR description to file + fs.writeFileSync('pr-description.md', result.prDescription); + + // Exit with appropriate code + if (result.allFailed) { + console.error('All package updates failed'); + process.exit(1); + } + + console.log('Package updates completed'); + process.exit(0); + } catch (error) { + console.error('Fatal error:', error); + process.exit(1); + } +} + +main(); +``` + +**File:** `tools/scripts/backend-package-updater/updater.ts` + +```typescript +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as glob from 'glob'; + +interface UpdateResult { + path: string; + success: boolean; + updates?: PackageUpdate[]; + error?: string; +} + +interface PackageUpdate { + package: string; + oldVersion: string; + newVersion: string; +} + +export async function updateBackendPackages(branch: string) { + // 1. Find all **/assets/package.json files + const packageJsonPaths = glob.sync('**/assets/package.json'); + + // 2. Auto-detect NPM scopes + const scopes = detectScopes(packageJsonPaths); + + // 3. Determine dist tag + const distTag = getDistTag(branch); + + // 4. Update packages in each assets folder + const results: UpdateResult[] = []; + + for (const pkgPath of packageJsonPaths) { + const assetsDir = path.dirname(pkgPath); + + try { + const updates = updatePackagesInFolder(assetsDir, scopes, distTag); + results.push({ path: assetsDir, success: true, updates }); + } catch (error) { + results.push({ + path: assetsDir, + success: false, + error: error.message, + }); + } + } + + // 5. Generate PR description + const prDescription = generatePRDescription(results, branch, distTag, scopes); + + // 6. Determine if all failed + const allFailed = results.every((r) => !r.success); + + return { results, prDescription, allFailed }; +} + +function detectScopes(packageJsonPaths: string[]): Set { + const scopes = new Set(); + + for (const pkgPath of packageJsonPaths) { + const pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const deps = { ...pkgJson.dependencies, ...pkgJson.devDependencies }; + + for (const dep of Object.keys(deps)) { + if (dep.startsWith('@')) { + const scope = dep.split('/')[0]; + scopes.add(scope); + } + } + } + + return scopes; +} + +function getDistTag(branchName: string): string { + if (branchName === 'main' || branchName === 'master') { + return 'snapshot'; + } + + if (branchName.startsWith('release/')) { + return branchName.replace('release/', 'release-'); + } + + throw new Error(`Unsupported branch pattern: ${branchName}`); +} + +function updatePackagesInFolder( + assetsDir: string, + scopes: Set, + distTag: string +): PackageUpdate[] { + const updates: PackageUpdate[] = []; + + // Read current versions + const pkgJsonPath = path.join(assetsDir, 'package.json'); + const pkgJsonBefore = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); + + // Update packages from each scope + for (const scope of scopes) { + execSync(`npm install ${scope}/*@${distTag}`, { + cwd: assetsDir, + stdio: 'inherit', + }); + } + + // Read updated versions + const pkgJsonAfter = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); + + // Compare and collect changes + const depsBefore = { + ...pkgJsonBefore.dependencies, + ...pkgJsonBefore.devDependencies, + }; + const depsAfter = { + ...pkgJsonAfter.dependencies, + ...pkgJsonAfter.devDependencies, + }; + + for (const [pkg, newVersion] of Object.entries(depsAfter)) { + const oldVersion = depsBefore[pkg]; + if (oldVersion && oldVersion !== newVersion) { + updates.push({ package: pkg, oldVersion, newVersion }); + } + } + + return updates; +} + +function generatePRDescription( + results: UpdateResult[], + branch: string, + distTag: string, + scopes: Set +): string { + const successful = results.filter((r) => r.success); + const failed = results.filter((r) => !r.success); + + let description = `## Frontend Package Updates\n\n`; + description += `**Branch:** ${branch}\n`; + description += `**Dist Tag:** ${distTag}\n`; + description += `**Auto-detected scopes:** ${Array.from(scopes).join( + ', ' + )}\n\n`; + + if (successful.length > 0) { + description += `### ✅ Successfully Updated (${successful.length}/${results.length})\n\n`; + for (const result of successful) { + description += `#### ${result.path}\n`; + if (result.updates && result.updates.length > 0) { + for (const update of result.updates) { + description += `- ${update.package}: ${update.oldVersion} → ${update.newVersion}\n`; + } + } else { + description += `- No changes (already up to date)\n`; + } + description += `\n`; + } + } + + if (failed.length > 0) { + description += `### ❌ Failed to Update (${failed.length}/${results.length})\n\n`; + for (const result of failed) { + description += `#### ${result.path}\n`; + description += `**Error:** ${result.error}\n\n`; + } + } + + return description; +} +``` + +#### 4. Composite Action (Optional) + +**File:** `.github/actions/backend-package-updater/action.yml` (optional wrapper) + +```yaml +name: 'Backend Package Updater' +description: 'Updates frontend packages in backend repository assets folders' + +runs: + using: 'composite' + steps: + - run: cd "$GITHUB_ACTION_PATH/../../.." && npm ci + shell: bash + - run: npx ts-node "$GITHUB_ACTION_PATH/../../../tools/scripts/backend-package-updater/main.ts" + shell: bash +``` + +### Integration Points + +#### Frontend Repository Setup + +Frontend repositories (e.g., `cplace-fe`, `cplace-paw-fe`) need to: + +1. Add `BACKEND_REPO` input to their workflow templates: + +```yaml +# .github/workflows/main-branch.yml (or similar) +jobs: + snapshot: + needs: build + uses: collaborationFactory/github-actions/.github/workflows/fe-snapshot.yml@master + with: + GHA_REF: ${{ github.ref }} + GHA_BASE: main + BACKEND_REPO: main # ← Add this + secrets: + JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} + JFROG_URL: ${{ secrets.JFROG_URL }} + JFROG_USER: ${{ secrets.JFROG_USER }} +``` + +2. Ensure organization secret `CROSS_REPO_PAT` exists and is accessible + +#### Backend Repository Setup + +Backend repositories (e.g., `main`, `cplace-paw`) need to: + +1. Add workflow file to listen for dispatch events: + +```yaml +# .github/workflows/frontend-package-update.yml +name: Frontend Package Update + +on: + repository_dispatch: + types: [frontend-packages-published] + +jobs: + update: + uses: collaborationFactory/github-actions/.github/workflows/be-package-update.yml@master + with: + branch: ${{ github.event.client_payload.branch }} + actor: ${{ github.event.client_payload.actor }} + secrets: + JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} + JFROG_URL: ${{ secrets.JFROG_URL }} + JFROG_USER: ${{ secrets.JFROG_USER }} +``` + +2. Ensure JFrog secrets are configured +3. No additional configuration needed (scopes auto-detected) + +### Data Flow + +``` +Frontend Developer + ↓ (git push to release/25.4) + +Frontend Repo Workflow (fe-release.yml) + ↓ (build & publish packages) + ↓ (success) + ↓ (repository_dispatch) + +Backend Repo Workflow (listens for dispatch) + ↓ (calls be-package-update.yml@master) + +github-actions Repo (be-package-update.yml) + ↓ (checkout backend repo at release/25.4) + ↓ (setup Node.js) + ↓ (configure JFrog credentials) + ↓ (execute TypeScript script) + +TypeScript Script (backend-package-updater) + ↓ (scan **/assets/package.json) + ↓ (detect scopes: @cplace-next, @cplace-paw) + ↓ (resolve dist tag: release-25.4) + ↓ (for each assets folder:) + ↓ (cd {assets-dir}) + ↓ (npm install @cplace-next/*@release-25.4) + ↓ (npm install @cplace-paw/*@release-25.4) + ↓ (collect results) + ↓ (generate PR description) + +Backend Repo Workflow + ↓ (commit changes to new branch) + ↓ (create PR with summary) + ↓ (attempt to assign to triggering developer) + +Pull Request Created + ↓ (ready for review) +``` + +## Trade-offs & Risks + +### Accepted Trade-offs + +1. **Partial Updates Allowed** + + - **What we're accepting:** PRs may contain updates for only some plugins if others fail + - **To gain:** Higher resilience - one problematic folder doesn't block all updates + - **Mitigation:** Clear reporting in PR description shows exactly what failed and why + +2. **PAT Token Maintenance** + + - **What we're accepting:** Need to maintain organization-level PAT with expiration monitoring + - **To gain:** Automated cross-repository triggering without complex GitHub App setup + - **Mitigation:** Use organization secret (not per-repo), set long expiration (1 year), monitor expiration + +3. **Auto-Detected Scopes Only** + + - **What we're accepting:** Can only update scopes already present in package.json files + - **To gain:** Zero configuration overhead across all backend repositories + - **Mitigation:** New scopes require one-time manual addition to any package.json, then auto-detected thereafter + +4. **No Version Validation Before Update** + + - **What we're accepting:** Don't verify dist tag exists in registry before attempting install + - **To gain:** Faster execution (fewer API calls), simpler implementation + - **Mitigation:** Clear error reporting if npm install fails due to missing tag/version + +5. **Command May Run Twice on Assignment Failure** + - **What we're accepting:** `gh pr create` may execute twice if assignee permission fails + - **To gain:** Simple bash implementation with automatic fallback + - **Mitigation:** First command fails gracefully, second command succeeds, PR always created + +### Known Risks + +1. **PAT Token Expiration** + + - **Description:** If `CROSS_REPO_PAT` expires, cross-repo triggering silently fails + - **Impact:** Backend repos stop receiving updates automatically + - **Probability:** Medium (tokens expire after 90 days to 1 year) + - **Mitigation:** + - Use 1-year expiration for PAT + - Set up expiration monitoring/alerts + - Document PAT renewal process + - Consider GitHub App in future for auto-expiring tokens + +2. **Branch Name Mismatch** + + - **Description:** If branch doesn't exist in backend repo, workflow fails + - **Impact:** No PR created, update doesn't happen + - **Probability:** Low (frontend and backend branches usually aligned) + - **Mitigation:** + - Workflow fails with clear error message + - Teams can manually trigger update on different branch + - Future enhancement: Fallback to default branch + +3. **JFrog Registry Downtime** + + - **Description:** If JFrog registry is unavailable, npm install commands fail + - **Impact:** All package updates fail, PR not created + - **Probability:** Low (JFrog generally reliable) + - **Mitigation:** + - Workflow retry logic (GitHub Actions auto-retries on failure) + - Clear error message indicates registry issue + - Can manually re-run workflow when registry recovers + +4. **Breaking Changes in Package Updates** + + - **Description:** New frontend package versions may contain breaking changes + - **Impact:** Backend code may break after updates + - **Probability:** Medium (depends on frontend development practices) + - **Mitigation:** + - PR creates isolated branch for review + - Backend tests should run on PR (separate CI workflow) + - Team reviews PR before merging + - Can revert PR if issues found + +5. **Permission Issues in Backend Repo** + + - **Description:** Workflow may lack permissions to create branches or PRs + - **Impact:** Update fails, no PR created + - **Probability:** Low (GITHUB_TOKEN has write access by default) + - **Mitigation:** + - Ensure backend repo workflow has `contents: write` and `pull-requests: write` permissions + - Clear error message if permission denied + - Document required permissions + +6. **Conflicting Updates** + - **Description:** If multiple frontend repos trigger updates simultaneously to same backend repo + - **Impact:** Race condition - second update might conflict with first + - **Probability:** Low (different frontend repos typically target different backend repos) + - **Mitigation:** + - Each update creates unique branch name with `${{ github.run_id }}` + - PRs created separately, can be merged sequentially + - GitHub prevents same-branch conflicts automatically + +## Out of Scope + +The following items are explicitly **not included** in this design: + +1. **Automatic PR Merging**: PRs require manual review and approval before merging +2. **Rollback on Test Failure**: If backend tests fail on the PR, manual intervention required (no auto-rollback) +3. **Version Pinning Strategies**: Always updates to latest version with specified dist tag (no semantic version constraints like `^` or `~`) +4. **Multi-Repository Updates**: One backend repo updated per frontend workflow run (no bulk updates to multiple backend repos) +5. **Notification System**: No Slack/email notifications on update success/failure (beyond GitHub's native PR notifications) +6. **Update Scheduling**: No delayed or scheduled updates (always immediate after frontend publish) +7. **Dependency Conflict Resolution**: Script doesn't resolve npm peer dependency conflicts (npm handles this) +8. **Custom Update Logic per Plugin**: All plugins updated with same strategy (no per-plugin customization) +9. **Historical Version Tracking**: No database or log of update history beyond git commit history +10. **Frontend Package Selection**: Always updates ALL packages from detected scopes (no selective package updates) + +## Success Criteria + +How will we know this design is successful? + +### Functional Criteria + +1. **Automated End-to-End Flow** + + - ✅ Frontend workflow publishes packages to JFrog + - ✅ Backend workflow automatically triggers within 1 minute + - ✅ Backend PR created without manual intervention + +2. **Correct Version Updates** + + - ✅ Main branch updates use `snapshot` dist tag + - ✅ Release branches use correct `release-{major}.{minor}` dist tag + - ✅ All `**/assets/package.json` files updated with matching versions + +3. **Comprehensive PR Information** + + - ✅ PR description lists all updated packages with version changes + - ✅ PR description clearly shows failed updates with error messages + - ✅ PR assigned to triggering developer (or no assignee if permission denied) + +4. **Resilient to Partial Failures** + - ✅ One failed plugin folder doesn't block others from updating + - ✅ Clear reporting distinguishes successful vs failed updates + - ✅ Workflow succeeds if at least one folder updates successfully + +### Non-Functional Criteria + +1. **Performance** + + - ✅ Complete update cycle (trigger to PR creation) under 5 minutes for typical backend repo + - ✅ Script handles 20+ assets folders without timeout + +2. **Reliability** + + - ✅ 95%+ success rate for workflows (excluding expected failures like branch mismatches) + - ✅ Clear error messages for all failure scenarios + - ✅ No silent failures (all errors logged and reported) + +3. **Maintainability** + + - ✅ TypeScript code follows existing patterns in `tools/scripts/` + - ✅ Workflow structure consistent with existing frontend workflows + - ✅ Clear documentation in code comments + - ✅ Easy to add new branch patterns or dist tag mappings + +4. **Usability** + - ✅ Zero configuration required in backend repositories (beyond initial workflow file) + - ✅ Frontend repos only need to add `BACKEND_REPO` input parameter + - ✅ PR descriptions provide actionable information for reviewers + +## Next Steps + +1. **Review this design document** + + - Gather feedback from frontend and backend teams + - Validate assumptions about repository structures + - Confirm branch naming conventions + +2. **Refine if needed based on feedback** + + - Adjust error handling strategies + - Modify PR description format + - Update dist tag resolution logic + +3. **Proceed to implementation planning:** + + ```bash + /create_plan thoughts/shared/designs/backend-frontend-package-automation.md + ``` + + This will create a detailed implementation plan with specific tasks, file changes, and testing strategy. + +## References + +- **Original Ticket:** (to be added) +- **Related Research:** `/Users/slavenkopic/Desktop/cplace-dev/repos/github-actions/thoughts/shared/research/2025-11-26_13-58-35_frontend-package-publishing-and-backend-installation.md` +- **Existing Workflows:** + - Frontend snapshot workflow: `.github/workflows/fe-snapshot.yml` + - Frontend release workflow: `.github/workflows/fe-release.yml` + - Artifacts action: `.github/actions/artifacts/action.yml` + - Artifacts scripts: `tools/scripts/artifacts/` +- **GitHub Documentation:** + - Repository Dispatch Events: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#repository_dispatch + - Reusable Workflows: https://docs.github.com/en/actions/using-workflows/reusing-workflows diff --git a/thoughts/shared/plans/backend-frontend-package-automation-implementation.md b/thoughts/shared/plans/backend-frontend-package-automation-implementation.md new file mode 100644 index 00000000..7be44ce9 --- /dev/null +++ b/thoughts/shared/plans/backend-frontend-package-automation-implementation.md @@ -0,0 +1,1402 @@ +# Backend Frontend Package Automation - Implementation Plan + +## Overview + +Implement automated frontend package updates in backend repositories after successful publishing to JFrog NPM Registry. This plan covers modifications to existing frontend workflows, creation of a new backend update workflow, and TypeScript scripts to handle package discovery and updates. + +## Current State Analysis + +### Existing Infrastructure + +**Frontend Publishing Workflows**: + +- `.github/workflows/fe-snapshot.yml:1-48` - Publishes snapshot versions from main/master branches +- `.github/workflows/fe-release.yml:1-59` - Publishes release versions from tags + +**Artifacts System**: + +- `.github/actions/artifacts/action.yml:1-12` - Composite action executing TypeScript via ts-node +- `tools/scripts/artifacts/` - Complete TypeScript codebase for artifact management + - Pattern: Entry point (`main.ts`) → Handler class → Utilities → Models + - JFrog authentication via environment variables (`jfrog-credentials.ts:1-19`) + - `.npmrc` generation per project (`nx-project.ts:243-263`) + +**Workflow Patterns**: + +- All workflows use `workflow_call` trigger (no existing `repository_dispatch` usage) +- Secrets passed as environment variables to composite actions +- Common patterns: `printenv`, `cd "$GITHUB_ACTION_PATH/../../.." && npm ci`, `npx ts-node` + +**Dependencies** (`package.json:25-28`): + +- `@actions/core` - GitHub Actions logging +- No glob libraries (uses shell `ls` with glob patterns via `execSync`) + +### What's Missing + +- ❌ No `repository_dispatch` events in any workflow +- ❌ No cross-repository triggering mechanism +- ❌ No backend package update workflows +- ❌ No TypeScript scripts for `**/assets/package.json` scanning +- ❌ No automatic dist tag resolution based on branch names +- ❌ No PR creation with package update summaries + +### Key Constraints Discovered + +1. **File Discovery Pattern**: Use shell glob via `execSync('ls */**/pattern')` (line 18 of `utils.ts`) +2. **No npm ci in backend workflow**: Can't run `npm ci` at backend repo root (backend is not Node.js monorepo) +3. **Separate npm install per folder**: Must run `npm install` in each `**/assets` directory independently +4. **GitHub CLI available**: Can use `gh pr create` for PR creation (no Octokit dependency needed) +5. **.npmrc per backend workflow**: Must configure JFrog authentication for backend workflow + +## Desired End State + +### Verification Criteria + +**Automated Verification:** + +- [ ] Modified `fe-snapshot.yml` accepts `BACKEND_REPO` input parameter +- [ ] Modified `fe-release.yml` accepts `BACKEND_REPO` input parameter +- [ ] New workflow file `.github/workflows/be-package-update.yml` exists +- [ ] TypeScript scripts in `tools/scripts/backend-package-updater/` compile successfully: `npx tsc --noEmit` +- [ ] Unit tests pass: `npm test -- backend-package-updater` +- [ ] Composite action file `.github/actions/backend-package-updater/action.yml` exists + +**Manual Verification:** + +- [ ] Frontend workflow triggers backend workflow after publishing (check GitHub Actions UI) +- [ ] Backend workflow creates branch with updated package.json files +- [ ] PR is created in backend repo with correct title and description +- [ ] PR shows all package version changes in description +- [ ] PR is assigned to triggering user (or no assignee if no permission) +- [ ] Dist tag resolution works correctly (main → snapshot, release/25.4 → release-25.4) +- [ ] Multiple `**/assets` folders are all updated in single PR +- [ ] Failed updates are clearly reported in PR description + +## What We're NOT Doing + +1. **Automatic PR Merging**: PRs require manual review +2. **Test Execution**: Backend tests are separate workflow responsibility +3. **Rollback Logic**: If updates fail, manual intervention required +4. **Version Validation**: No pre-check if dist tags exist in registry +5. **Custom Scope Configuration**: Scopes auto-detected only, no config files +6. **GitHub App**: Using organization PAT instead +7. **Notifications**: No Slack/email beyond GitHub's native PR notifications +8. **Multi-Repository Updates**: One backend repo per frontend workflow invocation +9. **Branch Creation in Backend**: If branch doesn't exist in backend, workflow fails (no auto-create) +10. **NPM Lock File Regeneration**: Using `npm install` which respects existing lock files + +## Implementation Approach + +### High-Level Strategy + +1. **Phase 1**: Modify existing frontend workflows to dispatch events (minimal changes, low risk) +2. **Phase 2**: Create TypeScript scripts for backend updates (core logic, testable in isolation) +3. **Phase 3**: Create backend workflow and composite action (integration layer) +4. **Phase 4**: Integration testing and documentation + +This phasing allows incremental testing and rollback if issues occur. + +--- + +## Phase 1: Modify Frontend Workflows + +### Overview + +Add repository_dispatch capability to existing frontend workflows without breaking current functionality. + +### Changes Required + +#### 1. Update `fe-snapshot.yml` + +**File**: `.github/workflows/fe-snapshot.yml` + +**Line 12 (add new input)**: + +```yaml +on: + workflow_call: + inputs: + GHA_REF: + type: string + required: true + GHA_BASE: + type: string + required: true + BACKEND_REPO: # ← NEW INPUT + type: string + required: false + description: 'Backend repository name to trigger (e.g., "main" or "cplace-paw")' + secrets: + JFROG_BASE64_TOKEN: + required: true + JFROG_URL: + required: true + JFROG_USER: + required: true + GIT_USER_TOKEN: + required: false + CROSS_REPO_PAT: # ← NEW SECRET + required: false +``` + +**After line 48 (add new step)**: + +```yaml +- name: Trigger Backend Package Update + if: inputs.BACKEND_REPO != '' + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.CROSS_REPO_PAT }} + repository: collaborationFactory/${{ inputs.BACKEND_REPO }} + event-type: frontend-packages-published + client-payload: | + { + "branch": "${{ github.ref_name }}", + "actor": "${{ github.actor }}" + } +``` + +**Rationale**: + +- Optional input ensures backward compatibility (existing workflows won't break) +- `github.ref_name` extracts branch name (e.g., "main", "release/25.4") from `refs/heads/main` +- `github.actor` provides username for PR assignment +- Conditional `if` prevents execution when `BACKEND_REPO` not provided + +#### 2. Update `fe-release.yml` + +**File**: `.github/workflows/fe-release.yml` + +**After line 13 (add new input)**: + +```yaml +on: + workflow_call: + secrets: + JFROG_BASE64_TOKEN: + required: true + JFROG_URL: + required: true + JFROG_USER: + required: true + DOT_NPMRC: + required: true + CROSS_REPO_PAT: # ← NEW SECRET + required: false + inputs: + BACKEND_REPO: # ← NEW INPUT + type: string + required: false + description: 'Backend repository name to trigger' +``` + +**After line 59 (add new step)**: + +```yaml +- name: Trigger Backend Package Update + if: inputs.BACKEND_REPO != '' + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.CROSS_REPO_PAT }} + repository: collaborationFactory/${{ inputs.BACKEND_REPO }} + event-type: frontend-packages-published + client-payload: | + { + "branch": "${{ github.ref_name }}", + "actor": "${{ github.actor }}" + } +``` + +**Note**: Unlike `fe-snapshot.yml`, release workflow extracts tag via `dawidd6/action-get-tag@v1` (line 50), but `github.ref_name` still provides the branch/tag name correctly. + +### Success Criteria + +#### Automated Verification: + +- [ ] YAML syntax validation: `yamllint .github/workflows/fe-snapshot.yml` +- [ ] YAML syntax validation: `yamllint .github/workflows/fe-release.yml` +- [ ] Workflows don't break existing calls (test with empty `BACKEND_REPO`) + +#### Manual Verification: + +- [ ] Frontend workflow completes successfully without `BACKEND_REPO` input +- [ ] With `BACKEND_REPO` input, workflow attempts repository_dispatch (check logs) +- [ ] Repository dispatch event visible in backend repo's Actions → "All workflows" → filter by event type + +--- + +## Phase 2: Create TypeScript Backend Package Updater Scripts + +### Overview + +Create TypeScript scripts following existing `tools/scripts/artifacts/` patterns for discovering assets folders, detecting scopes, resolving dist tags, updating packages, and generating PR descriptions. + +### Directory Structure + +Create: `tools/scripts/backend-package-updater/` + +``` +tools/scripts/backend-package-updater/ +├── main.ts # Entry point +├── updater.ts # Core update logic +├── dist-tag-resolver.ts # Branch to dist tag mapping +├── utils.ts # Utility functions (glob, file operations) +├── types.ts # TypeScript interfaces +├── updater.test.ts # Tests for updater +├── dist-tag-resolver.test.ts # Tests for dist tag resolution +└── utils.test.ts # Tests for utilities +``` + +### Changes Required + +#### 1. Create `types.ts` + +**File**: `tools/scripts/backend-package-updater/types.ts` + +```typescript +export interface UpdateResult { + path: string; + success: boolean; + updates?: PackageUpdate[]; + error?: string; +} + +export interface PackageUpdate { + package: string; + oldVersion: string; + newVersion: string; +} + +export interface UpdateSummary { + results: UpdateResult[]; + prDescription: string; + allFailed: boolean; + branch: string; + distTag: string; + scopes: Set; +} +``` + +**Rationale**: Follows existing pattern in `tools/scripts/artifacts/types.ts` + +#### 2. Create `dist-tag-resolver.ts` + +**File**: `tools/scripts/backend-package-updater/dist-tag-resolver.ts` + +```typescript +export class DistTagResolver { + /** + * Determines the NPM dist tag based on branch name. + * + * @param branchName - Git branch name (e.g., "main", "release/25.4") + * @returns NPM dist tag (e.g., "snapshot", "release-25.4") + * @throws Error if branch pattern is unsupported + * + * Supported patterns: + * - "main" or "master" → "snapshot" + * - "release/X.Y" → "release-X.Y" + */ + public static getDistTag(branchName: string): string { + if (branchName === 'main' || branchName === 'master') { + return 'snapshot'; + } + + if (branchName.startsWith('release/')) { + // Extract version: release/25.4 → release-25.4 + const version = branchName.replace('release/', 'release-'); + return version; + } + + throw new Error( + `Unsupported branch pattern: ${branchName}. ` + + `Supported patterns: main, master, release/*` + ); + } +} +``` + +**Rationale**: + +- Simple, explicit mapping (design decision #3) +- Fail-fast with clear error message +- Static method (no state needed) +- Follows pattern from `tools/scripts/artifacts/version.ts` + +#### 3. Create `utils.ts` + +**File**: `tools/scripts/backend-package-updater/utils.ts` + +```typescript +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +export class BackendPackageUtils { + /** + * Finds all assets/package.json files in the repository. + * Uses shell glob pattern via ls command (matches existing pattern in artifacts/utils.ts:17-26) + * + * @returns Array of paths to package.json files (e.g., ["plugins/plugin-a/assets/package.json"]) + */ + public static findAssetsPackageJsonFiles(): string[] { + try { + const result = execSync( + 'find . -path "*/assets/package.json" -not -path "*/node_modules/*" -not -path "*/dist/*"' + ) + .toString() + .trim(); + + if (!result) { + console.log('No assets/package.json files found'); + return []; + } + + const files = result.split('\n').filter((f) => f.length > 0); + console.log(`Found ${files.length} assets/package.json files:`); + files.forEach((f) => console.log(` - ${f}`)); + + return files; + } catch (error) { + console.error('Error finding assets/package.json files:', error); + return []; + } + } + + /** + * Extracts unique NPM scopes from package.json dependencies. + * + * @param packageJsonPaths - Array of paths to package.json files + * @returns Set of scopes (e.g., Set(["@cplace-next", "@cplace-paw"])) + */ + public static detectScopes(packageJsonPaths: string[]): Set { + const scopes = new Set(); + + for (const pkgPath of packageJsonPaths) { + if (!fs.existsSync(pkgPath)) { + console.warn(`Package.json not found: ${pkgPath}`); + continue; + } + + try { + const pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const deps = { + ...pkgJson.dependencies, + ...pkgJson.devDependencies, + }; + + for (const dep of Object.keys(deps)) { + if (dep.startsWith('@')) { + const scope = dep.split('/')[0]; // @cplace-next/pkg → @cplace-next + scopes.add(scope); + } + } + } catch (error) { + console.error(`Error reading ${pkgPath}:`, error); + } + } + + console.log(`Detected scopes: ${Array.from(scopes).join(', ')}`); + return scopes; + } + + /** + * Gets root directory of git repository. + * Follows pattern from tools/scripts/artifacts/utils.ts:230-232 + */ + public static getRootDir(): string { + return execSync(`git rev-parse --show-toplevel`).toString().trim(); + } + + /** + * Writes PR description to file for consumption by workflow. + */ + public static writePRDescription( + description: string, + filename: string = 'pr-description.md' + ): void { + const filePath = path.join(BackendPackageUtils.getRootDir(), filename); + fs.writeFileSync(filePath, description); + console.log(`PR description written to: ${filePath}`); + } +} +``` + +**Rationale**: + +- Uses `find` instead of `ls` with glob (more reliable for `**/assets` pattern) +- Follows existing utils pattern (static methods, execSync usage) +- Error handling with try-catch and console logging +- Similar to `tools/scripts/artifacts/utils.ts` structure + +#### 4. Create `updater.ts` + +**File**: `tools/scripts/backend-package-updater/updater.ts` + +```typescript +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { UpdateResult, PackageUpdate, UpdateSummary } from './types'; +import { BackendPackageUtils } from './utils'; +import { DistTagResolver } from './dist-tag-resolver'; + +export class BackendPackageUpdater { + /** + * Main entry point for updating backend packages. + * Follows pattern from tools/scripts/artifacts/artifacts-handler.ts:handle() + * + * @param branch - Git branch name from dispatch payload + * @returns Update summary with results and PR description + */ + public static async updateBackendPackages( + branch: string + ): Promise { + console.log(`\n=== Backend Package Update ===`); + console.log(`Branch: ${branch}`); + + // 1. Find all **/assets/package.json files + const packageJsonPaths = BackendPackageUtils.findAssetsPackageJsonFiles(); + + if (packageJsonPaths.length === 0) { + throw new Error('No assets/package.json files found in repository'); + } + + // 2. Auto-detect NPM scopes + const scopes = BackendPackageUtils.detectScopes(packageJsonPaths); + + if (scopes.size === 0) { + throw new Error('No scoped packages found in package.json files'); + } + + // 3. Determine dist tag + const distTag = DistTagResolver.getDistTag(branch); + console.log(`Using dist tag: ${distTag}`); + + // 4. Update packages in each assets folder + const results: UpdateResult[] = []; + + for (const pkgPath of packageJsonPaths) { + const assetsDir = path.dirname(pkgPath); + console.log(`\n--- Processing: ${assetsDir} ---`); + + try { + const updates = this.updatePackagesInFolder(assetsDir, scopes, distTag); + results.push({ + path: assetsDir, + success: true, + updates, + }); + console.log(`✓ Successfully updated ${updates.length} packages`); + } catch (error: any) { + console.error(`✗ Failed to update: ${error.message}`); + results.push({ + path: assetsDir, + success: false, + error: error.message, + }); + } + } + + // 5. Generate PR description + const prDescription = this.generatePRDescription( + results, + branch, + distTag, + scopes + ); + + // 6. Determine if all failed + const allFailed = results.every((r) => !r.success); + + return { + results, + prDescription, + allFailed, + branch, + distTag, + scopes, + }; + } + + /** + * Updates packages in a single assets folder. + * Follows error handling pattern from tools/scripts/artifacts/nx-project.ts:publish() + * + * @param assetsDir - Path to assets directory + * @param scopes - Set of NPM scopes to update + * @param distTag - NPM dist tag to install + * @returns Array of package updates + */ + private static updatePackagesInFolder( + assetsDir: string, + scopes: Set, + distTag: string + ): PackageUpdate[] { + const updates: PackageUpdate[] = []; + const pkgJsonPath = path.join(assetsDir, 'package.json'); + + // Verify package.json exists + if (!fs.existsSync(pkgJsonPath)) { + throw new Error(`package.json not found at ${pkgJsonPath}`); + } + + // Read current versions + const pkgJsonBefore = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); + const depsBefore = { + ...(pkgJsonBefore.dependencies || {}), + ...(pkgJsonBefore.devDependencies || {}), + }; + + // Update packages from each scope + for (const scope of scopes) { + const packagesPattern = `${scope}/*@${distTag}`; + console.log(` Installing: ${packagesPattern}`); + + try { + execSync(`npm install ${packagesPattern}`, { + cwd: assetsDir, + stdio: 'pipe', // Capture output instead of inherit + }); + } catch (error: any) { + // npm install exits with code 1 if packages not found + console.warn(` Warning: ${error.message}`); + // Continue with other scopes instead of failing completely + } + } + + // Read updated versions + const pkgJsonAfter = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); + const depsAfter = { + ...(pkgJsonAfter.dependencies || {}), + ...(pkgJsonAfter.devDependencies || {}), + }; + + // Compare and collect changes + for (const [pkg, newVersion] of Object.entries(depsAfter) as [ + string, + string + ][]) { + const oldVersion = depsBefore[pkg]; + if (oldVersion && oldVersion !== newVersion) { + updates.push({ + package: pkg, + oldVersion: oldVersion as string, + newVersion, + }); + console.log(` ↑ ${pkg}: ${oldVersion} → ${newVersion}`); + } + } + + if (updates.length === 0) { + console.log(` No updates (packages already at latest version)`); + } + + return updates; + } + + /** + * Generates PR description from update results. + * Follows pattern from tools/scripts/artifacts/utils.ts:writePublishedProjectToGithubCommentsFile() + * + * @param results - Array of update results + * @param branch - Git branch name + * @param distTag - NPM dist tag used + * @param scopes - Set of scopes processed + * @returns Markdown-formatted PR description + */ + private static generatePRDescription( + results: UpdateResult[], + branch: string, + distTag: string, + scopes: Set + ): string { + const successful = results.filter((r) => r.success); + const failed = results.filter((r) => !r.success); + + let description = `## Frontend Package Updates\n\n`; + description += `**Branch:** ${branch}\n`; + description += `**Dist Tag:** ${distTag}\n`; + description += `**Auto-detected scopes:** ${Array.from(scopes).join( + ', ' + )}\n\n`; + + if (successful.length > 0) { + description += `### ✅ Successfully Updated (${successful.length}/${results.length})\n\n`; + for (const result of successful) { + description += `#### ${result.path}\n`; + if (result.updates && result.updates.length > 0) { + for (const update of result.updates) { + description += `- ${update.package}: ${update.oldVersion} → ${update.newVersion}\n`; + } + } else { + description += `- No changes (already up to date)\n`; + } + description += `\n`; + } + } + + if (failed.length > 0) { + description += `### ❌ Failed to Update (${failed.length}/${results.length})\n\n`; + for (const result of failed) { + description += `#### ${result.path}\n`; + description += `**Error:** ${result.error}\n\n`; + } + } + + description += `---\n\n`; + description += `🤖 Generated with [Claude Code](https://claude.com/claude-code)\n`; + + return description; + } +} +``` + +**Rationale**: + +- Class with static methods (no state, matches artifacts pattern) +- Continue on error pattern (design decision #6) +- Detailed console logging for debugging +- Follows existing error handling patterns from `tools/scripts/artifacts/` + +#### 5. Create `main.ts` + +**File**: `tools/scripts/backend-package-updater/main.ts` + +```typescript +import { BackendPackageUpdater } from './updater'; +import { BackendPackageUtils } from './utils'; + +/** + * Entry point for backend package updater. + * Follows pattern from tools/scripts/artifacts/main.ts + */ +async function main() { + try { + // Read environment variables (set by workflow) + const branch = process.env.BRANCH || ''; + const actor = process.env.ACTOR || ''; + + if (!branch) { + console.error('ERROR: BRANCH environment variable is required'); + process.exit(1); + } + + console.log(`Triggered by: ${actor || 'unknown'}`); + + // Execute update + const result = await BackendPackageUpdater.updateBackendPackages(branch); + + // Write PR description to file (consumed by workflow) + BackendPackageUtils.writePRDescription(result.prDescription); + + // Print summary + console.log(`\n=== Summary ===`); + console.log(`Total folders: ${result.results.length}`); + console.log( + `Successful: ${result.results.filter((r) => r.success).length}` + ); + console.log(`Failed: ${result.results.filter((r) => !r.success).length}`); + + // Exit with appropriate code + if (result.allFailed) { + console.error('\n❌ All package updates failed'); + process.exit(1); + } + + console.log('\n✓ Package updates completed'); + process.exit(0); + } catch (error: any) { + console.error('\n❌ Fatal error:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +main(); +``` + +**Rationale**: + +- Minimal entry point (follows `tools/scripts/artifacts/main.ts:1-4`) +- Environment variable validation +- Clear exit codes (0 = success, 1 = failure) +- Fatal error handling at top level + +#### 6. Create Tests + +**File**: `tools/scripts/backend-package-updater/dist-tag-resolver.test.ts` + +```typescript +import { DistTagResolver } from './dist-tag-resolver'; + +describe('DistTagResolver', () => { + describe('getDistTag', () => { + test('returns "snapshot" for main branch', () => { + expect(DistTagResolver.getDistTag('main')).toBe('snapshot'); + }); + + test('returns "snapshot" for master branch', () => { + expect(DistTagResolver.getDistTag('master')).toBe('snapshot'); + }); + + test('converts release/25.4 to release-25.4', () => { + expect(DistTagResolver.getDistTag('release/25.4')).toBe('release-25.4'); + }); + + test('converts release/22.3 to release-22.3', () => { + expect(DistTagResolver.getDistTag('release/22.3')).toBe('release-22.3'); + }); + + test('throws error for unsupported branch pattern', () => { + expect(() => DistTagResolver.getDistTag('feature/my-feature')).toThrow( + 'Unsupported branch pattern: feature/my-feature' + ); + }); + + test('throws error for empty string', () => { + expect(() => DistTagResolver.getDistTag('')).toThrow( + 'Unsupported branch pattern' + ); + }); + }); +}); +``` + +**File**: `tools/scripts/backend-package-updater/utils.test.ts` + +```typescript +import { BackendPackageUtils } from './utils'; +import * as child_process from 'child_process'; +import * as fs from 'fs'; + +jest.mock('child_process'); +jest.mock('fs'); + +describe('BackendPackageUtils', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('findAssetsPackageJsonFiles', () => { + test('finds assets package.json files', () => { + const mockOutput = + 'plugins/plugin-a/assets/package.json\nplugins/plugin-b/assets/package.json'; + jest + .spyOn(child_process, 'execSync') + .mockReturnValue(Buffer.from(mockOutput)); + + const result = BackendPackageUtils.findAssetsPackageJsonFiles(); + + expect(result).toEqual([ + 'plugins/plugin-a/assets/package.json', + 'plugins/plugin-b/assets/package.json', + ]); + }); + + test('returns empty array when no files found', () => { + jest.spyOn(child_process, 'execSync').mockReturnValue(Buffer.from('')); + + const result = BackendPackageUtils.findAssetsPackageJsonFiles(); + + expect(result).toEqual([]); + }); + }); + + describe('detectScopes', () => { + test('extracts scopes from package.json dependencies', () => { + const mockPackageJson = { + dependencies: { + '@cplace-next/cf-shell': '1.0.0', + '@cplace-paw/components': '2.0.0', + 'regular-package': '3.0.0', + }, + }; + + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest + .spyOn(fs, 'readFileSync') + .mockReturnValue(JSON.stringify(mockPackageJson)); + + const result = BackendPackageUtils.detectScopes(['test/package.json']); + + expect(result).toEqual(new Set(['@cplace-next', '@cplace-paw'])); + }); + + test('handles missing package.json gracefully', () => { + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + jest.spyOn(console, 'warn').mockImplementation(); + + const result = BackendPackageUtils.detectScopes(['missing/package.json']); + + expect(result.size).toBe(0); + expect(console.warn).toHaveBeenCalled(); + }); + }); +}); +``` + +**Rationale**: Follows testing patterns from `tools/scripts/artifacts/*.test.ts` + +### Success Criteria + +#### Automated Verification: + +- [ ] TypeScript compiles: `npx tsc --noEmit` +- [ ] Tests pass: `npm test -- backend-package-updater` +- [ ] Linting passes: `npm run check-prettier` +- [ ] No import errors when running: `npx ts-node tools/scripts/backend-package-updater/main.ts` + +#### Manual Verification: + +- [ ] Script execution with `BRANCH=main` environment variable produces expected output +- [ ] Script correctly identifies test `**/assets/package.json` files +- [ ] Dist tag resolution works for all branch patterns +- [ ] Error handling behaves correctly (e.g., invalid branch name) + +--- + +## Phase 3: Create Backend Workflow and Composite Action + +### Overview + +Create GitHub workflow that listens for repository_dispatch events and composite action that wraps TypeScript execution. + +### Changes Required + +#### 1. Create Composite Action + +**File**: `.github/actions/backend-package-updater/action.yml` + +```yaml +name: 'Backend Package Updater' +description: 'Updates frontend packages in backend repository assets folders' +runs: + using: 'composite' + steps: + - run: printenv + shell: bash + - run: cd "$GITHUB_ACTION_PATH/../../.." && pwd && npm ci + shell: bash + - run: npx ts-node "$GITHUB_ACTION_PATH/../../../tools/scripts/backend-package-updater/main.ts" + shell: bash +``` + +**Rationale**: + +- Exact pattern from `.github/actions/artifacts/action.yml:1-12` +- No inputs (uses environment variables) +- Runs `npm ci` at repo root to install dependencies including ts-node + +#### 2. Create Backend Update Workflow + +**File**: `.github/workflows/be-package-update.yml` + +```yaml +name: Backend Package Update + +on: + repository_dispatch: + types: [frontend-packages-published] + workflow_call: + inputs: + branch: + type: string + required: true + description: 'Branch to update packages on' + actor: + type: string + required: true + description: 'GitHub username who triggered the update' + secrets: + JFROG_BASE64_TOKEN: + required: true + JFROG_URL: + required: true + JFROG_USER: + required: true + +jobs: + update-packages: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Set variables from trigger type + id: set-vars + run: | + if [ "${{ github.event_name }}" = "workflow_call" ]; then + echo "branch=${{ inputs.branch }}" >> $GITHUB_OUTPUT + echo "actor=${{ inputs.actor }}" >> $GITHUB_OUTPUT + else + # repository_dispatch + echo "branch=${{ github.event.client_payload.branch }}" >> $GITHUB_OUTPUT + echo "actor=${{ github.event.client_payload.actor }}" >> $GITHUB_OUTPUT + fi + echo "Branch: $(cat $GITHUB_OUTPUT | grep branch)" + echo "Actor: $(cat $GITHUB_OUTPUT | grep actor)" + + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ steps.set-vars.outputs.branch }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 22.15.0 + + - name: Configure NPM Registry + run: | + cat > .npmrc << 'EOF' + @${{ secrets.JFROG_USER }}:registry=${{ secrets.JFROG_URL }} + ${{ secrets.JFROG_URL }}:_auth=${{ secrets.JFROG_BASE64_TOKEN }} + ${{ secrets.JFROG_URL }}:always-auth=true + EOF + echo "NPM registry configured" + + - name: Update Frontend Packages + uses: collaborationFactory/github-actions/.github/actions/backend-package-updater@master + env: + BRANCH: ${{ steps.set-vars.outputs.branch }} + ACTOR: ${{ steps.set-vars.outputs.actor }} + + - name: Check for changes + id: check-changes + run: | + if git diff --quiet; then + echo "has_changes=false" >> $GITHUB_OUTPUT + echo "No changes detected" + else + echo "has_changes=true" >> $GITHUB_OUTPUT + echo "Changes detected" + git status --short + fi + + - name: Commit Changes + if: steps.check-changes.outputs.has_changes == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + BRANCH_NAME="chore/update-frontend-packages-${{ github.run_id }}" + git checkout -b "$BRANCH_NAME" + git add . + git commit -m "chore: Update frontend packages from ${{ steps.set-vars.outputs.branch }}" + git push origin HEAD + echo "branch_name=$BRANCH_NAME" >> $GITHUB_ENV + + - name: Create Pull Request + if: steps.check-changes.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BASE_BRANCH="${{ steps.set-vars.outputs.branch }}" + ACTOR="${{ steps.set-vars.outputs.actor }}" + + # Try to create PR with assignee + gh pr create \ + --title "chore: Update frontend packages from $BASE_BRANCH" \ + --body-file pr-description.md \ + --head "${{ env.branch_name }}" \ + --base "$BASE_BRANCH" \ + --assignee "$ACTOR" || \ + # Fallback: create without assignee if first attempt fails + gh pr create \ + --title "chore: Update frontend packages from $BASE_BRANCH" \ + --body-file pr-description.md \ + --head "${{ env.branch_name }}" \ + --base "$BASE_BRANCH" + + - name: No Changes Summary + if: steps.check-changes.outputs.has_changes == 'false' + run: | + echo "✓ All packages are already up to date" + echo "No PR created" +``` + +**Key aspects**: + +- Dual trigger support (`repository_dispatch` and `workflow_call`) +- Setup job to normalize inputs from both triggers +- Check for changes before creating PR (avoids empty PRs) +- Try-catch pattern for PR assignment using `||` operator +- Uses `GITHUB_TOKEN` (automatically available with `contents: write` and `pull-requests: write`) +- `.npmrc` configuration inline (simpler than DOT_NPMRC for single workflow) + +**Rationale for .npmrc configuration**: + +- Backend workflow runs in backend repo (not this repo) +- Can't use composite action from this repo to configure .npmrc +- Inline configuration is clearer and more maintainable +- Matches pattern from design document + +#### 3. Update root package.json Scripts + +**File**: `package.json` + +**Add test pattern** (line 7): + +```json +{ + "scripts": { + "test": "jest --config=./jest.config.js", + "test:backend-updater": "jest --config=./jest.config.js backend-package-updater", + "check-prettier": "prettier --check .", + "write-prettier": "prettier --write ." + } +} +``` + +### Success Criteria + +#### Automated Verification: + +- [ ] Workflow YAML is valid: `yamllint .github/workflows/be-package-update.yml` +- [ ] Action YAML is valid: `yamllint .github/actions/backend-package-updater/action.yml` +- [ ] Workflow syntax check passes in GitHub UI + +#### Manual Verification: + +- [ ] Workflow can be manually triggered via `workflow_call` from another workflow +- [ ] Workflow correctly extracts branch and actor from payload +- [ ] Workflow checks out correct branch +- [ ] NPM registry authentication works (check npm install logs) +- [ ] Composite action executes TypeScript script successfully +- [ ] PR is created with correct title and body +- [ ] PR assignment works (or gracefully falls back) + +--- + +## Phase 4: Integration Testing and Documentation + +### Overview + +Test end-to-end integration and create documentation for setup in frontend/backend repositories. + +### Testing Strategy + +#### Unit Tests + +Already covered in Phase 2: + +- `dist-tag-resolver.test.ts` - Branch to dist tag mapping +- `utils.test.ts` - File discovery and scope detection +- `updater.test.ts` - Package update logic + +#### Integration Tests (Manual) + +**Test 1: Frontend to Backend Dispatch** + +1. Create test backend repository with `**/assets/package.json` files +2. Add `.github/workflows/frontend-package-update.yml` calling `be-package-update.yml` +3. Trigger `fe-snapshot.yml` with `BACKEND_REPO` input +4. Verify dispatch event reaches backend +5. Verify backend workflow executes + +**Test 2: Package Updates** + +1. Backend workflow runs on test repository +2. Verify all `**/assets/package.json` files are discovered +3. Verify scopes are auto-detected +4. Verify npm install executes in each assets folder +5. Verify package-lock.json is updated + +**Test 3: PR Creation** + +1. Verify branch is created with changes +2. Verify PR is created with correct title +3. Verify PR body contains detailed summary +4. Verify PR is assigned to triggering user + +**Test 4: Error Handling** + +1. Test with invalid branch name (should fail with clear error) +2. Test with missing package in registry (should continue with warning) +3. Test with one failed assets folder (should continue with others) +4. Test when all updates fail (workflow should fail) + +**Test 5: Edge Cases** + +1. Test with no changes needed (all packages up to date) +2. Test with empty `**/assets` folders +3. Test with no scoped packages in package.json +4. Test with permission error on PR assignment + +### Documentation + +#### 1. Create Setup Guide for Backend Repositories + +**File**: Create `docs/backend-setup.md` (not in implementation plan scope, mentioned for completeness) + +Content: + +- How to add workflow file to backend repository +- Required secrets configuration +- Example workflow file +- Troubleshooting guide + +#### 2. Create Setup Guide for Frontend Repositories + +**File**: Create `docs/frontend-setup.md` (not in implementation plan scope, mentioned for completeness) + +Content: + +- How to add `BACKEND_REPO` input to workflow calls +- How to configure organization PAT +- Example workflow modifications +- Troubleshooting guide + +#### 3. Update Existing Documentation + +Update any existing README or docs that reference frontend workflows to mention new backend integration capability. + +### Success Criteria + +#### Automated Verification: + +- [ ] All unit tests pass: `npm test` +- [ ] TypeScript compilation succeeds: `npx tsc --noEmit` +- [ ] Prettier formatting passes: `npm run check-prettier` + +#### Manual Verification: + +- [ ] End-to-end test: Frontend publish → Backend dispatch → PR created +- [ ] PR contains accurate package version changes +- [ ] PR description is well-formatted and informative +- [ ] Error scenarios handled gracefully with clear messages +- [ ] No false positives (empty PRs, incorrect updates, etc.) +- [ ] Performance acceptable (completes within 5 minutes for typical backend repo) + +--- + +## Testing Strategy + +### Unit Tests + +**Location**: `tools/scripts/backend-package-updater/*.test.ts` + +**What to test**: + +- Dist tag resolution for all branch patterns +- Scope detection from package.json +- File discovery with various directory structures +- PR description generation with different result combinations +- Error handling for missing files, invalid JSON, etc. + +**Test pattern** (following `tools/scripts/artifacts/*.test.ts`): + +```typescript +describe('ClassName', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + test('description', () => { + // Arrange: Mock file system, execSync + jest.spyOn(fs, 'readFileSync').mockReturnValue('...'); + + // Act: Call method + const result = method(); + + // Assert: Verify result + expect(result).toBe(expected); + }); +}); +``` + +### Integration Tests + +**Manual testing checklist**: + +1. **Test in development environment first**: + + - Fork backend repo for testing + - Add test workflow file + - Manually trigger with test payloads + +2. **Test all branch patterns**: + + - `main` → snapshot + - `master` → snapshot + - `release/25.4` → release-25.4 + - Invalid branch → clear error + +3. **Test error scenarios**: + + - Missing package in registry + - One folder fails, others succeed + - All folders fail + - No scoped packages found + +4. **Test PR creation**: + - With valid assignee + - With invalid assignee (no permission) + - With no changes (should not create PR) + +### Performance Testing + +**Target**: Complete workflow in < 5 minutes for backend repo with 20 assets folders + +**Measure**: + +- Time to discover all package.json files +- Time per assets folder for npm install +- Total workflow execution time + +**Optimization if needed**: + +- Parallel npm install (future enhancement) +- Caching of npm registry queries + +--- + +## Performance Considerations + +### Expected Performance + +**Typical backend repo** (10 assets folders, 5 scoped packages each): + +- File discovery: < 5 seconds +- Scope detection: < 5 seconds +- npm install per folder: 30-60 seconds (depends on network, cache) +- Total npm installs: 5-10 minutes +- PR creation: < 10 seconds +- **Total: 5-10 minutes** (within target) + +### Potential Bottlenecks + +1. **NPM install is sequential**: Currently updates one folder at a time + + - Mitigation: Acceptable for v1, can parallelize in future + +2. **Network latency to JFrog**: Multiple registry queries + + - Mitigation: npm caches locally, subsequent installs faster + +3. **Large number of assets folders**: Linear time increase + - Mitigation: Typical repos have 5-20 folders, still under 10 minutes + +### Optimizations (Future Enhancements, Out of Scope) + +1. Parallel npm install using Promise.all() +2. Batch npm install commands +3. Cache npm packages between workflow runs +4. Only update packages that have new versions (pre-check registry) + +--- + +## Migration Notes + +### Organization Setup + +1. **Create organization PAT**: + + - Go to GitHub Organization Settings → Developer settings → Personal access tokens + - Create fine-grained token with: + - Name: "Cross-Repo Workflow Dispatch" + - Expiration: 1 year + - Repository access: All repositories (or specific ones) + - Permissions: `contents: read`, `metadata: read`, `actions: write` + - Copy token value + +2. **Add organization secret**: + + - Go to Organization Settings → Secrets and variables → Actions + - New organization secret: + - Name: `CROSS_REPO_PAT` + - Value: (paste token) + - Repository access: All repositories + +3. **Document token expiration**: + - Add calendar reminder for token renewal + - Document renewal process + - Consider GitHub App for auto-expiring tokens (future) + +### Frontend Repository Setup + +**For each frontend repository** (cplace-fe, cplace-paw-fe): + +1. Update workflow file that calls `fe-snapshot.yml`: + + ```yaml + jobs: + snapshot: + uses: collaborationFactory/github-actions/.github/workflows/fe-snapshot.yml@master + with: + GHA_REF: ${{ github.ref }} + GHA_BASE: main + BACKEND_REPO: main # ← ADD THIS + secrets: + JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} + JFROG_URL: ${{ secrets.JFROG_URL }} + JFROG_USER: ${{ secrets.JFROG_USER }} + CROSS_REPO_PAT: ${{ secrets.CROSS_REPO_PAT }} # ← ADD THIS + ``` + +2. Same for `fe-release.yml` calls + +### Backend Repository Setup + +**For each backend repository** (main, cplace-paw): + +1. Create `.github/workflows/frontend-package-update.yml`: + + ```yaml + name: Frontend Package Update + + on: + repository_dispatch: + types: [frontend-packages-published] + + jobs: + update: + uses: collaborationFactory/github-actions/.github/workflows/be-package-update.yml@master + with: + branch: ${{ github.event.client_payload.branch }} + actor: ${{ github.event.client_payload.actor }} + secrets: + JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} + JFROG_URL: ${{ secrets.JFROG_URL }} + JFROG_USER: ${{ secrets.JFROG_USER }} + ``` + +2. Ensure JFrog secrets exist in repository settings + +3. No additional configuration needed (scopes auto-detected) + +### Rollback Plan + +If issues occur: + +1. **Disable dispatch in frontend workflows**: + + - Remove `BACKEND_REPO` input from workflow calls + - Existing workflows continue working normally + +2. **Disable backend workflows**: + + - Delete or rename `.github/workflows/frontend-package-update.yml` + - No automatic updates, manual process resumes + +3. **Revert code changes**: + - Frontend workflows: Revert commits adding dispatch step + - Backend workflow: Delete file + - TypeScript scripts: Delete directory + +--- + +## References + +- **Original Design:** `/Users/slavenkopic/Desktop/cplace-dev/repos/github-actions/thoughts/shared/designs/backend-frontend-package-automation.md` +- **Related Research:** `/Users/slavenkopic/Desktop/cplace-dev/repos/github-actions/thoughts/shared/research/2025-11-26_13-58-35_frontend-package-publishing-and-backend-installation.md` +- **Existing Patterns:** + - Artifacts scripts: `tools/scripts/artifacts/` + - Composite actions: `.github/actions/artifacts/action.yml` + - Frontend workflows: `.github/workflows/fe-snapshot.yml`, `.github/workflows/fe-release.yml` +- **GitHub Documentation:** + - Repository Dispatch: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#repository_dispatch + - Reusable Workflows: https://docs.github.com/en/actions/using-workflows/reusing-workflows + - PAT Authentication: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens diff --git a/thoughts/shared/research/2025-11-26_13-58-35_frontend-package-publishing-and-backend-installation.md b/thoughts/shared/research/2025-11-26_13-58-35_frontend-package-publishing-and-backend-installation.md new file mode 100644 index 00000000..e683a020 --- /dev/null +++ b/thoughts/shared/research/2025-11-26_13-58-35_frontend-package-publishing-and-backend-installation.md @@ -0,0 +1,745 @@ +--- +date: 2025-11-26T13:58:35+0000 +researcher: Claude +git_commit: a9c4ae4fbb2f6f179fed4af85bb52126870fa14c +branch: fix/PFM-ISSUE-31032-Clean-Snapshot-Action-Not-Working-for-Over- +repository: github-actions +topic: 'Frontend NPM Package Publishing and Backend Installation Workflow' +tags: + [ + research, + codebase, + frontend, + npm, + workflows, + publishing, + jfrog, + artifacts, + backend-installation, + ] +status: complete +last_updated: 2025-11-26 +last_updated_by: Claude +--- + +# Research: Frontend NPM Package Publishing and Backend Installation Workflow + +**Date**: 2025-11-26T13:58:35+0000 +**Researcher**: Claude +**Git Commit**: a9c4ae4fbb2f6f179fed4af85bb52126870fa14c +**Branch**: fix/PFM-ISSUE-31032-Clean-Snapshot-Action-Not-Working-for-Over- +**Repository**: github-actions + +## Research Question + +How are `.github/workflows/fe-snapshot.yml` and `.github/workflows/fe-release.yml` used in frontend repositories (cplace-fe, cplace-paw-fe) to publish NPM packages from main/master or release/\* branches, and what is the subsequent process for installing those packages in backend repositories (main, cplace-paw)? + +## Summary + +The research reveals a **complete frontend publishing workflow** but identifies a **critical gap in backend automation**: + +1. ✅ **Frontend Publishing**: Two reusable workflows (`fe-snapshot.yml` and `fe-release.yml`) handle NPM package publishing to JFrog Artifactory for snapshots and releases respectively +2. ✅ **Artifacts Action**: A sophisticated composite action (`.github/actions/artifacts`) manages building, versioning, and publishing packages with different version schemes +3. ✅ **Frontend Integration**: Frontend repos use workflow templates (`fe-main.yml`, `fe-tag-pushed.yml`) to trigger these publishing workflows +4. ❌ **Backend Installation**: **No automated workflow exists** to install published frontend packages in backend repositories - this must be done manually + +## Detailed Findings + +### 1. Frontend Publishing Workflows + +#### fe-snapshot.yml (`.github/workflows/fe-snapshot.yml:1-48`) + +**Purpose**: Publishes snapshot versions from main/master branches for continuous integration + +**Trigger**: `workflow_call` (reusable workflow) + +**Required Inputs**: + +- `GHA_REF`: Git reference to checkout +- `GHA_BASE`: Base branch for comparison (determines affected projects) + +**Required Secrets**: + +- `JFROG_BASE64_TOKEN`: Base64-encoded authentication token +- `JFROG_URL`: JFrog Artifactory URL (defaults to `https://cplace.jfrog.io/artifactory/cplace-npm-local`) +- `JFROG_USER`: JFrog username + +**Process**: + +1. Checks out specified ref with full history (`fetch-depth: 0`) +2. Sets up Node.js 22.15.0 +3. Caches node_modules for performance +4. Calls artifacts action with `SNAPSHOT=true` environment variable + +**Version Format**: `0.0.0-SNAPSHOT-{hashedTimestamp}` (e.g., `0.0.0-SNAPSHOT-lf8x7y9z-20231126`) + +**NPM Tag**: `snapshot` + +--- + +#### fe-release.yml (`.github/workflows/fe-release.yml:1-59`) + +**Purpose**: Publishes production releases when version tags are pushed + +**Trigger**: `workflow_call` (reusable workflow) + +**Required Secrets**: + +- `JFROG_BASE64_TOKEN`: JFrog authentication +- `JFROG_URL`: Registry URL +- `JFROG_USER`: JFrog username +- `DOT_NPMRC`: Complete .npmrc file content for additional registry configuration + +**Process**: + +1. Checks out repository with full history +2. Sets up Node.js 22.15.0 +3. Caches node_modules +4. Injects .npmrc configuration using `bduff9/use-npmrc@v1.1` +5. Installs dependencies if cache miss +6. Extracts tag using `dawidd6/action-get-tag@v1` +7. Calls artifacts action with extracted `TAG` value + +**Version Format**: Semantic versioning `{major}.{minor}.{patch}` (e.g., `22.3.1`) + +**Git Tag Format**: `version/{major}.{minor}.{patch}` (e.g., `version/22.3.1`) + +**NPM Tag**: `release-{major}.{minor}` (e.g., `release-22.3`) + +--- + +### 2. The Artifacts Action - Core Publishing Logic + +Located at `.github/actions/artifacts/action.yml:1-12`, this composite action is the heart of the publishing system. + +#### Architecture + +The action is a composite action that executes TypeScript code via `ts-node`: + +```yaml +runs: + using: 'composite' + steps: + - run: printenv + shell: bash + - run: cd "$GITHUB_ACTION_PATH/../../.." && pwd && npm ci + shell: bash + - run: npx ts-node "$GITHUB_ACTION_PATH/../../../tools/scripts/artifacts/main.ts" + shell: bash +``` + +**Entry Point**: `tools/scripts/artifacts/main.ts:3` +**Main Handler**: `tools/scripts/artifacts/artifacts-handler.ts` +**Project Management**: `tools/scripts/artifacts/nx-project.ts` +**Utilities**: `tools/scripts/artifacts/utils.ts` + +#### Task Determination Logic (`artifacts-handler.ts:58-91`) + +The action determines one of three operational modes based on environment variables: + +**1. RELEASE Mode** (triggered when): + +- `TAG` starts with `version/` (e.g., `version/22.3.1`) → `artifacts-handler.ts:59` +- OR current branch starts with `release/` (e.g., `release/22.3`) → `artifacts-handler.ts:66` + +Behavior: + +- Builds **ALL** projects (`onlyAffected = false`) → `artifacts-handler.ts:60` +- Uses semantic version from tag/branch → `artifacts-handler.ts:62, 67-69` +- Disables source maps for production → `nx-project.ts:132-135` +- Sets NPM tag to `release-{major}.{minor}` → `nx-project.ts:308` + +**2. MAIN_SNAPSHOT Mode** (triggered when): + +- `SNAPSHOT=true` environment variable is set → `artifacts-handler.ts:50-51` + +Behavior: + +- Builds **only affected** projects → default behavior +- Creates timestamp-based version: `0.0.0-SNAPSHOT-{hashedTimestamp}` → `artifacts-handler.ts:75-78` +- Hashed timestamp format: `{base36Timestamp}-{YYYYMMDD}` → `utils.ts:283-291` +- Includes source maps for debugging → `nx-project.ts:132-135` +- Sets NPM tag to `snapshot` → `nx-project.ts:309` + +**3. PR_SNAPSHOT Mode** (triggered when): + +- `PR_NUMBER` environment variable is provided → `artifacts-handler.ts:44-45` + +Behavior: + +- Builds **only affected** projects +- Creates PR-specific version: `0.0.0-{sanitizedBranch}-{prNumber}` → `artifacts-handler.ts:85-88` +- Branch name sanitized: truncated to 50 chars, special chars → dashes → `utils.ts:278-280` +- Example: `0.0.0-fix-PFM-ISSUE-31032-Clean-Snapshot-Action-No-123` +- **Deletes existing PR snapshot** before publishing → `nx-project.ts:129-130` +- Includes source maps → `nx-project.ts:132-135` +- Sets NPM tag to `latest-pr-snapshot` → `nx-project.ts:307` + +#### Project Discovery (`artifacts-handler.ts:137-166`) + +**Affected Projects Mode** (snapshots): + +- Uses NX CLI: `npx nx show projects --affected=true` → `utils.ts:45-54` +- Compares against base branch to find changed projects +- Includes apps and libraries +- Filters out E2E apps without `public_api.ts` → `utils.ts:65-71` +- Excludes projects starting with `api-` → `utils.ts:74` + +**All Projects Mode** (releases): + +- Uses NX CLI: `npx nx show projects --affected=false` → `utils.ts:85-117` +- Gets complete project list +- Same filtering rules apply + +#### Build Process (`nx-project.ts:128-139`) + +For each publishable project: + +```bash +npx nx build {projectName} --prod {--configuration=sourcemap} +``` + +- Production flag always enabled +- Source maps only for non-release tasks (snapshots, PR snapshots) +- Output directory: `dist/{apps|libs}/{projectPath}` → `nx-project.ts:316-324` + +#### NPM Package Preparation + +**1. .npmrc Generation** (`nx-project.ts:243-263`) + +Written to each project's dist folder: + +``` +@{scope}:registry={jfrogUrl} +{jfrogUrlNoHttp}:_auth={base64Token} +{jfrogUrlNoHttp}:always-auth=true +{jfrogUrlNoHttp}:email={jfrogUser} +``` + +- Scope parsed from root package.json → `utils.ts:351-370` +- Credentials never committed to repository +- URL format without protocol → `jfrog-credentials.ts:16-18` + +**2. package.json Update/Generation** (`nx-project.ts:265-304`) + +For libraries with existing package.json: + +- Reads from dist folder +- Updates: `version`, `author: "squad-fe"`, `publishConfig` + +For applications without package.json: + +- Generates minimal package.json with: + - `name: @{scope}/{projectName}` + - `version: {generatedVersion}` + - `author: "squad-fe"` + - `publishConfig`: registry, access: restricted, tag + +**3. FOSS List Copy** (`nx-project.ts:144-159`) + +- Copies `cplace-foss-list.json` from root to each project's dist +- Required for license compliance + +#### Publishing to JFrog (`nx-project.ts:108-126`) + +Process: + +1. For PR snapshots: delete existing version first → `nx-project.ts:129-130` +2. Execute `npm publish` from dist directory → `nx-project.ts:108-125` +3. Credentials provided via `.npmrc` file +4. Registry and tag specified in `package.json`'s `publishConfig` +5. On success: write to GitHub comments file → `nx-project.ts:116-118` +6. On failure: exit with code 1 + +**GitHub Comments File** (`utils.ts:315-349`): + +- Path: `{gitRoot}/githubCommentsForPR.txt` +- Contains list of published packages with timestamps (Berlin timezone) +- Used by subsequent workflow steps to comment on PRs + +#### Version Management (`version.ts:1-58`) + +**Version Class** manages semantic versioning with custom suffixes: + +```typescript +export class Version { + public major: number = -1; + public minor: number = -1; + public patch: number = -1; + public uniqueIdentifier: string = ''; + + constructor(versionString: string, public customSuffix: string = ''); + + public toString(): string; // Returns: major.minor.patch{suffix}{identifier} + public getGitTag(): string; // Returns: version/major.minor.patch + public getNpmTag(): string; // Returns: release-major.minor +} +``` + +**Version Bumping** (`utils.ts:193-206`): + +- For new release branches: creates `{major}.{minor}.1` +- For existing branches: increments patch number + +--- + +### 3. How Frontend Repositories Use These Workflows + +Frontend repositories (cplace-fe, cplace-paw-fe) use **workflow templates** that call the reusable workflows. + +#### Main Branch Publishing + +**Template**: `.github/workflow-templates/fe/fe-main.yml:39-48` + +```yaml +snapshot: + needs: build + uses: collaborationFactory/github-actions/.github/workflows/fe-snapshot.yml@master + with: + GHA_REF: ${{ github.ref }} + GHA_BASE: main # or master + secrets: + JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} + JFROG_URL: ${{ secrets.JFROG_URL }} + JFROG_USER: ${{ secrets.JFROG_USER }} +``` + +**When**: Triggered on push to main/master branch +**What**: Publishes snapshot versions of affected packages +**Version**: `0.0.0-SNAPSHOT-{timestamp}` + +#### Release Tag Publishing + +**Template**: `.github/workflow-templates/fe/fe-tag-pushed.yml:14-20` + +```yaml +release: + uses: collaborationFactory/github-actions/.github/workflows/fe-release.yml@master + secrets: + JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} + JFROG_URL: ${{ secrets.JFROG_URL }} + JFROG_USER: ${{ secrets.JFROG_USER }} + DOT_NPMRC: ${{ secrets.DOT_NPMRC }} +``` + +**When**: Triggered when tags matching `version/*` are pushed +**What**: Publishes production releases of ALL packages +**Version**: Semantic version from tag (e.g., `22.3.1`) + +#### PR Snapshot Publishing + +**Template**: `.github/workflow-templates/fe/fe-pr-snapshot.yml:11-19` + +```yaml +publish-pr-snapshot: + if: contains(github.event.pull_request.labels.*.name, 'snapshot') + uses: collaborationFactory/github-actions/.github/workflows/fe-pr-snapshot.yml@master + with: + GHA_BASE: ${{ github.event.pull_request.base.ref }} + secrets: + # same secrets as snapshot +``` + +**When**: Triggered on PRs with `snapshot` label +**What**: Publishes PR-specific versions of affected packages +**Version**: `0.0.0-{branch-name}-{prNumber}` + +--- + +### 4. Backend Installation - The Missing Piece + +#### Critical Finding: No Automation Exists + +After comprehensive search of the repository, **NO workflows, scripts, or automation** were found that: + +- Trigger installation in backend repositories after frontend publishing +- Update `package.json` in backend repos (main, cplace-paw) +- Use `repository_dispatch` or webhooks to notify backend repos +- Automatically install published NPM packages + +#### What Backend Repos Need to Do Manually + +To install a published frontend package, backend repositories must: + +**1. Identify the Published Package** + +From JFrog Artifactory: + +- **Registry**: `https://cplace.jfrog.io/artifactory/cplace-npm-local` +- **Package format**: `@{scope}/{package-name}@{version}` +- **Tags available**: + - `snapshot`: Latest main branch snapshot + - `latest-pr-snapshot`: Latest PR snapshot + - `release-{major}.{minor}`: Specific release version (e.g., `release-22.3`) + +**2. Configure NPM Registry Access** + +Create or update `.npmrc` in backend repo: + +``` +@{scope}:registry=https://cplace.jfrog.io/artifactory/cplace-npm-local +//cplace.jfrog.io/artifactory/cplace-npm-local/:_auth={base64Token} +//cplace.jfrog.io/artifactory/cplace-npm-local/:always-auth=true +//cplace.jfrog.io/artifactory/cplace-npm-local/:email={jfrogUser} +``` + +**3. Update package.json** + +Add or update dependency: + +```json +{ + "dependencies": { + "@cplace-next/package-name": "22.3.1" + } +} +``` + +Or for snapshots: + +```json +{ + "dependencies": { + "@cplace-next/package-name": "0.0.0-SNAPSHOT-lf8x7y9z-20231126" + } +} +``` + +**4. Install Dependencies** + +```bash +npm install +# or +npm update @cplace-next/package-name +``` + +#### Potential Automation Patterns (Not Currently Implemented) + +Based on patterns found in the codebase, backend automation could use: + +**Pattern 1: Repository Dispatch** (would need to be added) + +Frontend workflow could dispatch event after successful publish: + +```yaml +- name: Trigger Backend Update + uses: peter-evans/repository-dispatch@v2 + with: + token: ${{ secrets.PAT_TOKEN }} + repository: collaborationFactory/main + event-type: frontend-package-published + client-payload: '{"package": "${{ env.PACKAGE_NAME }}", "version": "${{ env.VERSION }}"}' +``` + +Backend repo would listen with `repository_dispatch` trigger: + +```yaml +on: + repository_dispatch: + types: [frontend-package-published] + +jobs: + update-frontend-package: + runs-on: ubuntu-latest + steps: + - name: Update package.json + run: | + npm install ${{ github.event.client_payload.package }}@${{ github.event.client_payload.version }} +``` + +**Pattern 2: Workflow Dispatch** (manual but streamlined) + +Backend repo could have a workflow_dispatch workflow: + +```yaml +on: + workflow_dispatch: + inputs: + package_name: + description: 'Frontend package to install' + required: true + version: + description: 'Version to install' + required: true + +jobs: + install-package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Update package + run: npm install ${{ inputs.package_name }}@${{ inputs.version }} + - name: Create PR + # commit changes and create PR +``` + +**Pattern 3: Scheduled Check** (polling approach) + +Backend repo could periodically check for new versions: + +```yaml +on: + schedule: + - cron: '0 */6 * * *' # Every 6 hours + +jobs: + check-updates: + runs-on: ubuntu-latest + steps: + - name: Check for updates + run: npm outdated --json > updates.json + - name: Create PR if updates available + # parse JSON and create PR with updates +``` + +--- + +### 5. NPM Registry Operations + +#### Publishing (`npm publish`) + +**Location**: `nx-project.ts:108-126` + +```typescript +execSync(`npm publish`, { + cwd: `${this.getPathToProjectInDist()}`, +}); +``` + +**Requirements**: + +- `.npmrc` with authentication in dist folder +- `package.json` with `publishConfig` in dist folder +- Built artifacts in dist folder + +**Behavior**: + +- Publishes to registry specified in `publishConfig.registry` +- Uses tag from `publishConfig.tag` +- Access level from `publishConfig.access` (always `restricted`) + +#### Checking Package Existence (`npm show`) + +**Location**: `nx-project.ts:174-186` + +```typescript +const scopeSearchResult = execSync(`npm show ${pkg} --json`).toString(); +const npmPackage = JSON.parse(scopeSearchResult); +return npmPackage.versions.includes(version); +``` + +**Returns**: Package metadata including all available versions + +#### Deleting Versions (`npm unpublish`) + +**Location**: `nx-project.ts:188-241` + +```typescript +execSync( + `npm unpublish ${this.scope}/${this.name}@${version.toString()} --force`, + { cwd: `${this.getPathToProjectInDist()}` } +); +``` + +**Used for**: + +- Removing PR snapshots before republishing +- Cleanup of old snapshots (>4 months old) + +#### Listing All Versions (`npm view`) + +**Location**: `utils.ts:294-307` + +```typescript +const snapshotVersions = JSON.parse( + execSync(`npm view ${scope}/${packageName} --json`, { + cwd: projectDistDir, + }).toString() +).versions; +``` + +**Used for**: Finding all versions for cleanup operations + +--- + +### 6. Snapshot Cleanup Automation + +**Location**: `tools/scripts/artifacts/cleanup-snapshots.ts:1-105` + +**Purpose**: Remove snapshots older than 4 months (release cadence) + +**Algorithm**: + +1. Search all packages in scope: `npm search @{scope} --json` +2. Get all versions: `npm view {package} --json` +3. Filter versions containing 'snapshot' +4. Parse date from version string: `0.0.0-SNAPSHOT-lf8x7y9z-20231126` → `20231126` +5. Calculate age using Luxon library +6. Delete versions older than 4 months + +**Triggered by**: Workflow template `.github/workflow-templates/fe/fe-cleanup-snapshots.yml` + +--- + +## Code References + +### Workflows + +- `.github/workflows/fe-snapshot.yml:1-48` - Snapshot publishing workflow +- `.github/workflows/fe-release.yml:1-59` - Release publishing workflow +- `.github/workflows/fe-pr-snapshot.yml:1-81` - PR snapshot workflow +- `.github/workflow-templates/fe/fe-main.yml:39-48` - Main branch template +- `.github/workflow-templates/fe/fe-tag-pushed.yml:14-20` - Tag pushed template + +### Artifacts Action + +- `.github/actions/artifacts/action.yml:1-12` - Composite action definition +- `tools/scripts/artifacts/main.ts:3` - Entry point +- `tools/scripts/artifacts/artifacts-handler.ts:38-188` - Main orchestration logic +- `tools/scripts/artifacts/nx-project.ts:108-324` - Project-level operations +- `tools/scripts/artifacts/utils.ts:45-349` - Utility functions +- `tools/scripts/artifacts/version.ts:1-58` - Version management +- `tools/scripts/artifacts/jfrog-credentials.ts:6-18` - JFrog authentication + +### Key Methods + +- `nx-project.ts:108-126` - `publish()` method +- `nx-project.ts:188-241` - `deleteArtifact()` method +- `nx-project.ts:243-263` - `writeNPMRCInDist()` method +- `nx-project.ts:265-304` - `setVersionOrGeneratePackageJsonInDist()` method +- `artifacts-handler.ts:58-91` - Task determination logic +- `utils.ts:273-292` - Snapshot version generation +- `utils.ts:193-206` - Release version bumping + +--- + +## Architecture Insights + +### Design Patterns + +1. **Reusable Workflows**: Frontend repos use the same publishing logic via `workflow_call` +2. **Composite Actions**: TypeScript business logic wrapped in GitHub Actions +3. **Environment-Based Configuration**: Behavior determined by environment variables (SNAPSHOT, TAG, PR_NUMBER) +4. **Affected-Only Publishing**: Efficiency through NX affected project detection +5. **Registry Isolation**: Scoped packages with restricted access +6. **Version Segregation**: Different tags for snapshots, PRs, and releases + +### Version Naming Strategy + +The version naming reveals the deployment model: + +- **Snapshots** (`0.0.0-SNAPSHOT-{timestamp}`): Ephemeral, continuous integration builds +- **PR Snapshots** (`0.0.0-{branch}-{pr}`): Feature testing, replaced on each push +- **Releases** (`{major}.{minor}.{patch}`): Permanent, semantic versions + +This strategy allows: + +- Parallel development (PR snapshots don't conflict) +- Easy cleanup (snapshots have timestamps) +- Clear production versions (semantic versioning) + +### Security Considerations + +1. **Credentials Never Committed**: `.npmrc` generated at runtime +2. **Base64 Token**: Authentication token stored as secret, base64-encoded +3. **Restricted Access**: All published packages have `access: restricted` +4. **Scoped Packages**: Namespace isolation with `@{scope}/` prefix + +### Monorepo Architecture (NX) + +The system is designed for NX monorepos: + +- **Project Discovery**: Uses `nx show projects` +- **Affected Detection**: Compares against base branch +- **Build System**: Uses `nx build` command +- **Project Types**: Distinguishes apps vs libs vs e2e + +--- + +## Historical Context (from thoughts/) + +No relevant historical context found in thoughts/ directory for this specific workflow system. + +--- + +## Related Research + +This is the initial research document on this topic. Future research could explore: + +- Backend repository structure and dependency management +- Implementation of automated backend installation +- Cross-repository dependency tracking +- JFrog Artifactory configuration and retention policies + +--- + +## Open Questions + +### Critical Gap: Backend Installation Automation + +**Question**: Why is there no automation to install published frontend packages in backend repositories? + +**Potential Reasons**: + +1. **Manual Control**: Backend teams may want to manually review and test frontend package updates before installation +2. **Version Pinning**: Backend repos may intentionally lag behind to ensure stability +3. **Testing Requirements**: Frontend package updates may require backend code changes +4. **Different Release Cycles**: Frontend and backend may have different release schedules +5. **Implementation Pending**: Automation may be planned but not yet implemented + +**Impact**: + +- Manual process introduces delay between frontend publishing and backend integration +- Potential for version mismatches between environments +- Additional coordination required between frontend and backend teams +- Risk of forgetting to update dependencies + +### Implementation Questions + +1. **Who should trigger backend installation?** + + - Frontend workflow after successful publish? + - Backend workflow on schedule? + - Manual trigger by backend team? + +2. **What version should be installed?** + + - Latest snapshot for development branches? + - Specific release version for release branches? + - How to handle multiple frontend packages? + +3. **How to handle failures?** + + - What if package installation breaks backend tests? + - Automatic rollback vs. notification? + - Should PRs be created for manual review? + +4. **Which backend repos need updates?** + - Only `main` and `cplace-paw`? + - Different packages for different repos? + - How to map frontend packages to backend repos? + +### Recommended Next Steps + +1. **Document Current Process**: Create runbook for manual installation in backend repos +2. **Identify Requirements**: Survey backend teams on automation needs +3. **Design Automation**: Choose pattern (repository_dispatch, scheduled, manual workflow_dispatch) +4. **Implement Pilot**: Start with one frontend package and one backend repo +5. **Monitor & Iterate**: Gather feedback and expand to other packages/repos + +--- + +## Conclusion + +The frontend NPM package publishing system is **robust and well-architected**, with clear separation of concerns, efficient affected-only publishing, and comprehensive version management. However, the **lack of backend installation automation** represents a significant gap in the end-to-end workflow. + +**Key Takeaways**: + +- ✅ Frontend publishing is automated and reliable +- ✅ Three distinct publishing modes (snapshots, PR snapshots, releases) +- ✅ Clear version naming and registry organization +- ❌ Backend installation is entirely manual +- ❌ No cross-repository automation or notifications +- ⚠️ Potential for version drift between frontend packages and backend dependencies + +**Recommendation**: Implement backend installation automation with appropriate safeguards (testing, PR-based review) to complete the CI/CD pipeline and reduce manual coordination overhead. diff --git a/tools/scripts/backend-package-updater/dist-tag-resolver.test.ts b/tools/scripts/backend-package-updater/dist-tag-resolver.test.ts new file mode 100644 index 00000000..bb628706 --- /dev/null +++ b/tools/scripts/backend-package-updater/dist-tag-resolver.test.ts @@ -0,0 +1,24 @@ +import { DistTagResolver } from './dist-tag-resolver'; + +describe('DistTagResolver', () => { + describe('getDistTag', () => { + it('should return "snapshot" for main branch', () => { + expect(DistTagResolver.getDistTag('main')).toBe('snapshot'); + }); + + it('should return "snapshot" for master branch', () => { + expect(DistTagResolver.getDistTag('master')).toBe('snapshot'); + }); + + it('should return "release-X.Y" for release/X.Y branches', () => { + expect(DistTagResolver.getDistTag('release/25.4')).toBe('release-25.4'); + expect(DistTagResolver.getDistTag('release/26.1')).toBe('release-26.1'); + }); + + it('should return "snapshot" for other branch patterns', () => { + expect(DistTagResolver.getDistTag('feature/abc')).toBe('snapshot'); + expect(DistTagResolver.getDistTag('develop')).toBe('snapshot'); + expect(DistTagResolver.getDistTag('bugfix/test')).toBe('snapshot'); + }); + }); +}); diff --git a/tools/scripts/backend-package-updater/dist-tag-resolver.ts b/tools/scripts/backend-package-updater/dist-tag-resolver.ts new file mode 100644 index 00000000..f6f99e92 --- /dev/null +++ b/tools/scripts/backend-package-updater/dist-tag-resolver.ts @@ -0,0 +1,21 @@ +export class DistTagResolver { + /** + * Determines the NPM dist tag based on branch name. + * + * @param branchName - Git branch name (e.g., "main", "release/25.4") + * @returns NPM dist tag (e.g., "snapshot", "release-25.4") + * @throws Error if branch pattern is unsupported + * + * Supported patterns: + * - "main" or "master" → "snapshot" + * - "release/X.Y" → "release-X.Y" + */ + public static getDistTag(branchName: string): string { + if (branchName.startsWith('release/')) { + // Extract version: release/25.4 → release-25.4 + return branchName.replace('release/', 'release-'); + } + + return 'snapshot'; + } +} diff --git a/tools/scripts/backend-package-updater/main.ts b/tools/scripts/backend-package-updater/main.ts new file mode 100644 index 00000000..21044fb4 --- /dev/null +++ b/tools/scripts/backend-package-updater/main.ts @@ -0,0 +1,50 @@ +import { BackendPackageUpdater } from './updater'; +import { BackendPackageUtils } from './utils'; + +/** + * Entry point for backend package updater. + * Follows pattern from tools/scripts/artifacts/main.ts + */ +async function main() { + try { + // Read environment variables (set by workflow) + const branch = process.env.BRANCH || ''; + const actor = process.env.ACTOR || ''; + + if (!branch) { + console.error('ERROR: BRANCH environment variable is required'); + process.exit(1); + } + + console.log(`Triggered by: ${actor || 'unknown'}`); + + // Execute update + const result = await BackendPackageUpdater.updateBackendPackages(branch); + + // Write PR description to file (consumed by workflow) + BackendPackageUtils.writePRDescription(result.prDescription); + + // Print summary + console.log(`\n=== Summary ===`); + console.log(`Total folders: ${result.results.length}`); + console.log( + `Successful: ${result.results.filter((r) => r.success).length}` + ); + console.log(`Failed: ${result.results.filter((r) => !r.success).length}`); + + // Exit with appropriate code + if (result.allFailed) { + console.error('\n❌ All package updates failed'); + process.exit(1); + } + + console.log('\n✓ Package updates completed'); + process.exit(0); + } catch (error: any) { + console.error('\n❌ Fatal error:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +main(); diff --git a/tools/scripts/backend-package-updater/types.ts b/tools/scripts/backend-package-updater/types.ts new file mode 100644 index 00000000..7be49594 --- /dev/null +++ b/tools/scripts/backend-package-updater/types.ts @@ -0,0 +1,21 @@ +export interface UpdateResult { + path: string; + success: boolean; + updates?: PackageUpdate[]; + error?: string; +} + +export interface PackageUpdate { + package: string; + oldVersion: string; + newVersion: string; +} + +export interface UpdateSummary { + results: UpdateResult[]; + prDescription: string; + allFailed: boolean; + branch: string; + distTag: string; + scopes: Set; +} diff --git a/tools/scripts/backend-package-updater/updater.ts b/tools/scripts/backend-package-updater/updater.ts new file mode 100644 index 00000000..c82eb443 --- /dev/null +++ b/tools/scripts/backend-package-updater/updater.ts @@ -0,0 +1,259 @@ +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { UpdateResult, PackageUpdate, UpdateSummary } from './types'; +import { BackendPackageUtils } from './utils'; +import { DistTagResolver } from './dist-tag-resolver'; + +export class BackendPackageUpdater { + /** + * Main entry point for updating backend packages. + * Follows pattern from tools/scripts/artifacts/artifacts-handler.ts:handle() + * + * @param branch - Git branch name from dispatch payload + * @returns Update summary with results and PR description + */ + public static async updateBackendPackages( + branch: string + ): Promise { + console.log(`\n=== Backend Package Update ===`); + console.log(`Branch: ${branch}`); + + // 1. Find all **/assets/package.json files + const packageJsonPaths = BackendPackageUtils.findAssetsPackageJsonFiles(); + + if (packageJsonPaths.length === 0) { + throw new Error('No assets/package.json files found in repository'); + } + + // 2. Auto-detect NPM scopes + const scopes = BackendPackageUtils.detectScopes(packageJsonPaths); + + if (scopes.size === 0) { + throw new Error('No scoped packages found in package.json files'); + } + + // 3. Determine dist tag + const distTag = DistTagResolver.getDistTag(branch); + console.log(`Using dist tag: ${distTag}`); + + // 4. Update packages in each assets folder + const results: UpdateResult[] = []; + + for (const pkgPath of packageJsonPaths) { + const assetsDir = path.dirname(pkgPath); + console.log(`\n--- Processing: ${assetsDir} ---`); + + try { + const updates = this.updatePackagesInFolder(assetsDir, scopes, distTag); + results.push({ + path: assetsDir, + success: true, + updates, + }); + console.log(`✓ Successfully updated ${updates.length} packages`); + } catch (error: any) { + console.error(`✗ Failed to update: ${error.message}`); + results.push({ + path: assetsDir, + success: false, + error: error.message, + }); + } + } + + // 5. Generate PR description + const prDescription = this.generatePRDescription( + results, + branch, + distTag, + scopes + ); + + // 6. Determine if all failed + const allFailed = results.every((r) => !r.success); + + return { + results, + prDescription, + allFailed, + branch, + distTag, + scopes, + }; + } + + /** + * Updates packages in a single assets folder. + * Follows error handling pattern from tools/scripts/artifacts/nx-project.ts:publish() + * + * @param assetsDir - Path to assets directory + * @param scopes - Set of NPM scopes to update + * @param distTag - NPM dist tag to install + * @returns Array of package updates + */ + private static updatePackagesInFolder( + assetsDir: string, + scopes: Set, + distTag: string + ): PackageUpdate[] { + const updates: PackageUpdate[] = []; + const pkgJsonPath = path.join(assetsDir, 'package.json'); + + // Verify package.json exists + if (!fs.existsSync(pkgJsonPath)) { + throw new Error(`package.json not found at ${pkgJsonPath}`); + } + + // Copy root .npmrc to assets directory to ensure authentication works + // Local .npmrc files override parent .npmrc, so we need to copy credentials + const rootNpmrc = path.join(process.cwd(), '.npmrc'); + const assetsNpmrc = path.join(assetsDir, '.npmrc'); + + if (fs.existsSync(rootNpmrc)) { + // Read existing assets .npmrc if it exists + let existingConfig = ''; + if (fs.existsSync(assetsNpmrc)) { + existingConfig = fs.readFileSync(assetsNpmrc, 'utf-8'); + } + + // Read root .npmrc with authentication + const rootConfig = fs.readFileSync(rootNpmrc, 'utf-8'); + + // Merge: root config first (authentication), then existing config (save-exact, etc) + const mergedConfig = rootConfig + '\n' + existingConfig; + fs.writeFileSync(assetsNpmrc, mergedConfig); + console.log(` ✓ Copied authentication from root .npmrc to ${assetsDir}`); + } + + // Read current versions + const pkgJsonBefore = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); + const depsBefore = { + ...(pkgJsonBefore.dependencies || {}), + ...(pkgJsonBefore.devDependencies || {}), + }; + + // Find packages that match the scopes + const packagesToUpdate: string[] = []; + for (const [pkgName] of Object.entries(depsBefore)) { + for (const scope of Array.from(scopes)) { + if (pkgName.startsWith(`${scope}/`)) { + packagesToUpdate.push(pkgName); + break; + } + } + } + + if (packagesToUpdate.length === 0) { + console.log( + ` No packages found matching scopes: ${Array.from(scopes).join(', ')}` + ); + return updates; + } + + console.log(` Found ${packagesToUpdate.length} packages to update`); + + // Update each package individually + for (const pkgName of packagesToUpdate) { + const packageWithTag = `${pkgName}@${distTag}`; + console.log(` Installing: ${packageWithTag}`); + + try { + execSync(`npm install ${packageWithTag}`, { + cwd: assetsDir, + stdio: 'pipe', // Capture output instead of inherit + }); + } catch (error: any) { + // npm install exits with code 1 if package/tag not found + console.warn( + ` Warning: Failed to update ${pkgName}: ${error.message}` + ); + // Continue with other packages instead of failing completely + } + } + + // Read updated versions + const pkgJsonAfter = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); + const depsAfter = { + ...(pkgJsonAfter.dependencies || {}), + ...(pkgJsonAfter.devDependencies || {}), + }; + + // Compare and collect changes + for (const [pkg, newVersion] of Object.entries(depsAfter) as [ + string, + string + ][]) { + const oldVersion = depsBefore[pkg]; + if (oldVersion && oldVersion !== newVersion) { + updates.push({ + package: pkg, + oldVersion: oldVersion as string, + newVersion, + }); + console.log(` ↑ ${pkg}: ${oldVersion} → ${newVersion}`); + } + } + + if (updates.length === 0) { + console.log(` No updates (packages already at latest version)`); + } + + return updates; + } + + /** + * Generates PR description from update results. + * Follows pattern from tools/scripts/artifacts/utils.ts:writePublishedProjectToGithubCommentsFile() + * + * @param results - Array of update results + * @param branch - Git branch name + * @param distTag - NPM dist tag used + * @param scopes - Set of scopes processed + * @returns Markdown-formatted PR description + */ + private static generatePRDescription( + results: UpdateResult[], + branch: string, + distTag: string, + scopes: Set + ): string { + const successful = results.filter((r) => r.success); + const failed = results.filter((r) => !r.success); + + let description = `## Frontend Package Updates\n\n`; + description += `**Branch:** ${branch}\n`; + description += `**Dist Tag:** ${distTag}\n`; + description += `**Auto-detected scopes:** ${Array.from(scopes).join( + ', ' + )}\n\n`; + + if (successful.length > 0) { + description += `### ✅ Successfully Updated (${successful.length}/${results.length})\n\n`; + for (const result of successful) { + description += `#### ${result.path}\n`; + if (result.updates && result.updates.length > 0) { + for (const update of result.updates) { + description += `- ${update.package}: ${update.oldVersion} → ${update.newVersion}\n`; + } + } else { + description += `- No changes (already up to date)\n`; + } + description += `\n`; + } + } + + if (failed.length > 0) { + description += `### ❌ Failed to Update (${failed.length}/${results.length})\n\n`; + for (const result of failed) { + description += `#### ${result.path}\n`; + description += `**Error:** ${result.error}\n\n`; + } + } + + description += `---\n\n`; + description += `🤖 Generated with [Claude Code](https://claude.com/claude-code)\n`; + + return description; + } +} diff --git a/tools/scripts/backend-package-updater/utils.test.ts b/tools/scripts/backend-package-updater/utils.test.ts new file mode 100644 index 00000000..477a5431 --- /dev/null +++ b/tools/scripts/backend-package-updater/utils.test.ts @@ -0,0 +1,90 @@ +import { BackendPackageUtils } from './utils'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Mock fs for scope detection tests +jest.mock('fs'); + +describe('BackendPackageUtils', () => { + describe('detectScopes', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should detect scopes from dependencies', () => { + const mockPackageJson = { + dependencies: { + '@cplace-next/core': '1.0.0', + '@cplace-paw/utils': '2.0.0', + lodash: '4.17.21', + }, + devDependencies: { + '@cplace-next/dev-tools': '1.5.0', + }, + }; + + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.readFileSync as jest.Mock).mockReturnValue( + JSON.stringify(mockPackageJson) + ); + + const scopes = BackendPackageUtils.detectScopes(['test/package.json']); + + expect(scopes.size).toBe(2); + expect(scopes.has('@cplace-next')).toBe(true); + expect(scopes.has('@cplace-paw')).toBe(true); + expect(scopes.has('lodash')).toBe(false); // Non-scoped package + }); + + it('should handle missing package.json files', () => { + (fs.existsSync as jest.Mock).mockReturnValue(false); + + const scopes = BackendPackageUtils.detectScopes(['missing/package.json']); + + expect(scopes.size).toBe(0); + }); + + it('should handle package.json without dependencies', () => { + const mockPackageJson = { + name: 'test-package', + }; + + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.readFileSync as jest.Mock).mockReturnValue( + JSON.stringify(mockPackageJson) + ); + + const scopes = BackendPackageUtils.detectScopes(['test/package.json']); + + expect(scopes.size).toBe(0); + }); + + it('should merge scopes from multiple package.json files', () => { + const mockPackageJson1 = { + dependencies: { + '@cplace-next/core': '1.0.0', + }, + }; + + const mockPackageJson2 = { + dependencies: { + '@cplace-paw/utils': '2.0.0', + }, + }; + + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.readFileSync as jest.Mock) + .mockReturnValueOnce(JSON.stringify(mockPackageJson1)) + .mockReturnValueOnce(JSON.stringify(mockPackageJson2)); + + const scopes = BackendPackageUtils.detectScopes([ + 'plugin1/assets/package.json', + 'plugin2/assets/package.json', + ]); + + expect(scopes.size).toBe(2); + expect(scopes.has('@cplace-next')).toBe(true); + expect(scopes.has('@cplace-paw')).toBe(true); + }); + }); +}); diff --git a/tools/scripts/backend-package-updater/utils.ts b/tools/scripts/backend-package-updater/utils.ts new file mode 100644 index 00000000..4ae7b3c2 --- /dev/null +++ b/tools/scripts/backend-package-updater/utils.ts @@ -0,0 +1,92 @@ +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +export class BackendPackageUtils { + /** + * Finds all assets/package.json files in the repository. + * Uses shell glob pattern via ls command (matches existing pattern in artifacts/utils.ts:17-26) + * + * @returns Array of paths to package.json files (e.g., ["plugins/plugin-a/assets/package.json"]) + */ + public static findAssetsPackageJsonFiles(): string[] { + try { + const result = execSync( + 'find . -path "*/assets/package.json" -not -path "*/node_modules/*" -not -path "*/dist/*"' + ) + .toString() + .trim(); + + if (!result) { + console.log('No assets/package.json files found'); + return []; + } + + const files = result.split('\n').filter((f) => f.length > 0); + console.log(`Found ${files.length} assets/package.json files:`); + files.forEach((f) => console.log(` - ${f}`)); + + return files; + } catch (error) { + console.error('Error finding assets/package.json files:', error); + return []; + } + } + + /** + * Extracts unique NPM scopes from package.json dependencies. + * + * @param packageJsonPaths - Array of paths to package.json files + * @returns Set of scopes (e.g., Set(["@cplace-next", "@cplace-paw"])) + */ + public static detectScopes(packageJsonPaths: string[]): Set { + const scopes = new Set(); + + for (const pkgPath of packageJsonPaths) { + if (!fs.existsSync(pkgPath)) { + console.warn(`Package.json not found: ${pkgPath}`); + continue; + } + + try { + const pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const deps = { + ...pkgJson.dependencies, + ...pkgJson.devDependencies, + }; + + for (const dep of Object.keys(deps)) { + if (dep.startsWith('@')) { + const scope = dep.split('/')[0]; // @cplace-next/pkg → @cplace-next + scopes.add(scope); + } + } + } catch (error) { + console.error(`Error reading ${pkgPath}:`, error); + } + } + + console.log(`Detected scopes: ${Array.from(scopes).join(', ')}`); + return scopes; + } + + /** + * Gets root directory of git repository. + * Follows pattern from tools/scripts/artifacts/utils.ts:230-232 + */ + public static getRootDir(): string { + return execSync(`git rev-parse --show-toplevel`).toString().trim(); + } + + /** + * Writes PR description to file for consumption by workflow. + */ + public static writePRDescription( + description: string, + filename: string = 'pr-description.md' + ): void { + const filePath = path.join(BackendPackageUtils.getRootDir(), filename); + fs.writeFileSync(filePath, description); + console.log(`PR description written to: ${filePath}`); + } +} diff --git a/tools/scripts/run-many/run-many.ts b/tools/scripts/run-many/run-many.ts index 386b7b9b..297201df 100644 --- a/tools/scripts/run-many/run-many.ts +++ b/tools/scripts/run-many/run-many.ts @@ -10,7 +10,7 @@ function getE2ECommand(command: string, base: string): string { function getCoverageCommand(command: string): string { const coverageEnabled = process.env.coverageEnabled === 'true'; core.info(`Coverage enabled: ${coverageEnabled}`); - if(coverageEnabled) { + if (coverageEnabled) { command = command.concat( ` --codeCoverage=true --coverageReporters=lcov --coverageReporters=html` );