From e1227b903b4b99cf8a75ccfbec522affa95c803f Mon Sep 17 00:00:00 2001 From: vikramsh2002 Date: Mon, 1 Jun 2026 22:32:07 +0530 Subject: [PATCH] Add one-click portfolio sync preview flow --- .github/workflows/portfolio-project-sync.yml | 156 +++++++++++++++++- README.md | 16 +- portfolio-v2/docs/portfolio-sync.md | 17 +- .../scripts/applyPortfolioProjectUpdate.mjs | 117 ++++++++++++- 4 files changed, 288 insertions(+), 18 deletions(-) diff --git a/.github/workflows/portfolio-project-sync.yml b/.github/workflows/portfolio-project-sync.yml index 344d8bb..1ec8d33 100644 --- a/.github/workflows/portfolio-project-sync.yml +++ b/.github/workflows/portfolio-project-sync.yml @@ -17,6 +17,31 @@ on: required: true default: false type: boolean + deploy_preview: + description: "Deploy a preview after creating the project PR." + required: true + default: true + type: boolean + preview_repository: + description: "Preview hosting repository in owner/name format." + required: true + default: "vikramsh2002/My-Portfolio-Preview" + type: string + preview_branch: + description: "Branch used by GitHub Pages in the preview repository." + required: true + default: "gh-pages" + type: string + preview_base: + description: "Vite base path for the preview Pages site." + required: true + default: "/My-Portfolio-Preview/" + type: string + preview_url: + description: "Public preview URL." + required: true + default: "https://vikramsh2002.github.io/My-Portfolio-Preview/" + type: string card_image_url: description: "Optional image URL to use for the project card when no live site screenshot is available." required: false @@ -42,6 +67,11 @@ jobs: UPDATE_TEXT: ${{ github.event.client_payload.linkedin_update || github.event.inputs.linkedin_update }} MODEL_ID: ${{ github.event.client_payload.model || github.event.inputs.model || 'openai/gpt-4o' }} AUTO_MERGE: ${{ github.event.client_payload.auto_merge || github.event.inputs.auto_merge || 'false' }} + DEPLOY_PREVIEW: ${{ github.event.client_payload.deploy_preview || github.event.inputs.deploy_preview || 'true' }} + PREVIEW_REPOSITORY: ${{ github.event.client_payload.preview_repository || github.event.inputs.preview_repository || 'vikramsh2002/My-Portfolio-Preview' }} + PREVIEW_BRANCH: ${{ github.event.client_payload.preview_branch || github.event.inputs.preview_branch || 'gh-pages' }} + PREVIEW_BASE: ${{ github.event.client_payload.preview_base || github.event.inputs.preview_base || '/My-Portfolio-Preview/' }} + PREVIEW_URL: ${{ github.event.client_payload.preview_url || github.event.inputs.preview_url || 'https://vikramsh2002.github.io/My-Portfolio-Preview/' }} CARD_IMAGE_URL: ${{ github.event.client_payload.card_image_url || github.event.inputs.card_image_url }} steps: - name: Checkout @@ -156,9 +186,121 @@ jobs: echo "Triggered Portfolio V2 CI for $BRANCH_NAME." >> "$GITHUB_STEP_SUMMARY" + - name: Build preview + id: preview_build + if: steps.pr.outputs.pr_url != '' && env.DEPLOY_PREVIEW == 'true' + continue-on-error: true + working-directory: portfolio-v2 + env: + VITE_BASE: ${{ env.PREVIEW_BASE }} + run: npm run build + + - name: Smoke check preview artifact + id: preview_smoke + if: steps.pr.outputs.pr_url != '' && env.DEPLOY_PREVIEW == 'true' && steps.preview_build.outcome == 'success' + continue-on-error: true + env: + PROJECT_TITLE: ${{ steps.apply.outputs.project_title }} + run: | + if [ -z "$PROJECT_TITLE" ]; then + echo "No project title was available for preview smoke checking." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + if grep -R --include='*.html' --include='*.js' --fixed-strings --quiet "$PROJECT_TITLE" portfolio-v2/dist; then + echo "Preview artifact contains project title: $PROJECT_TITLE" >> "$GITHUB_STEP_SUMMARY" + else + echo "Preview artifact does not contain expected project title: $PROJECT_TITLE" >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + - name: Deploy generated preview + id: preview_deploy + if: steps.pr.outputs.pr_url != '' && env.DEPLOY_PREVIEW == 'true' && steps.preview_build.outcome == 'success' && steps.preview_smoke.outcome == 'success' + continue-on-error: true + env: + PORTFOLIO_PREVIEW_TOKEN: ${{ secrets.PORTFOLIO_PREVIEW_TOKEN }} + SOURCE_REF: ${{ steps.pr.outputs.branch_name }} + run: | + set -euo pipefail + + if [ -z "$PORTFOLIO_PREVIEW_TOKEN" ]; then + echo "PORTFOLIO_PREVIEW_TOKEN is required to push to $PREVIEW_REPOSITORY." >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + SOURCE_SHA="$(git rev-parse HEAD)" + PREVIEW_LINK="${PREVIEW_URL%/}/?v=${SOURCE_SHA}#projects" + PREVIEW_REMOTE="https://x-access-token:${PORTFOLIO_PREVIEW_TOKEN}@github.com/${PREVIEW_REPOSITORY}.git" + + git clone --depth 1 --branch "$PREVIEW_BRANCH" "$PREVIEW_REMOTE" _preview || { + git clone --depth 1 "$PREVIEW_REMOTE" _preview + git -C _preview checkout --orphan "$PREVIEW_BRANCH" + git -C _preview rm -rf . || true + } + + find _preview -mindepth 1 \ + ! -path "_preview/.git" \ + ! -path "_preview/.git/*" \ + -exec rm -rf {} + + + cp -R portfolio-v2/dist/. _preview/ + touch _preview/.nojekyll + + cat > _preview/preview-metadata.json <> "$GITHUB_STEP_SUMMARY" + else + git -C _preview commit -m "Deploy preview for ${GITHUB_REPOSITORY}@${SOURCE_SHA::7}" + git -C _preview push origin "$PREVIEW_BRANCH" + fi + + { + echo "## Portfolio Preview" + echo + echo "- Preview URL: $PREVIEW_LINK" + echo "- Preview repository: $PREVIEW_REPOSITORY" + echo "- Preview branch: $PREVIEW_BRANCH" + echo "- Source ref: $SOURCE_REF" + echo "- Source SHA: $SOURCE_SHA" + } >> "$GITHUB_STEP_SUMMARY" + + echo "preview_link=$PREVIEW_LINK" >> "$GITHUB_OUTPUT" + echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" + + - name: Comment preview link on PR + if: steps.preview_deploy.outputs.preview_link != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ steps.pr.outputs.pr_url }} + PREVIEW_LINK: ${{ steps.preview_deploy.outputs.preview_link }} + SOURCE_REF: ${{ steps.pr.outputs.branch_name }} + SOURCE_SHA: ${{ steps.preview_deploy.outputs.source_sha }} + run: | + PR_NUMBER="${PR_URL##*/}" + gh pr comment "$PR_NUMBER" --body "Portfolio preview deployed: + + $PREVIEW_LINK + + Source: $SOURCE_REF @ ${SOURCE_SHA:0:7}" + - name: Ask for review on blocked update id: review_issue - if: always() && (steps.apply.outcome == 'failure' || steps.build.outcome == 'failure' || steps.apply.outputs.risk == 'high' || steps.apply.outputs.intent == 'needs_review' || steps.apply.outputs.action == 'needs_review' || steps.apply.outputs.image_status == 'missing' || steps.apply.outputs.image_status == 'failed') + if: always() && (steps.apply.outcome == 'failure' || steps.build.outcome == 'failure' || steps.apply.outputs.risk == 'high' || steps.apply.outputs.intent == 'needs_review' || steps.apply.outputs.action == 'needs_review' || steps.apply.outputs.image_status == 'missing' || steps.apply.outputs.image_status == 'failed' || (env.DEPLOY_PREVIEW == 'true' && (steps.preview_build.outcome == 'failure' || steps.preview_smoke.outcome == 'failure' || steps.preview_deploy.outcome == 'failure'))) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -174,6 +316,10 @@ jobs: echo "- Image status: ${{ steps.apply.outputs.image_status }}" echo "- Image path: ${{ steps.apply.outputs.image_path }}" echo "- Image source: ${{ steps.apply.outputs.image_source }}" + echo "- Preview build status: ${{ steps.preview_build.outcome }}" + echo "- Preview smoke status: ${{ steps.preview_smoke.outcome }}" + echo "- Preview deploy status: ${{ steps.preview_deploy.outcome }}" + echo "- Preview URL: ${{ steps.preview_deploy.outputs.preview_link }}" echo "- Workflow run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" echo if [ "${{ steps.apply.outputs.image_status }}" = "missing" ] || [ "${{ steps.apply.outputs.image_status }}" = "failed" ]; then @@ -234,8 +380,14 @@ jobs: echo "Review email sent to $ALERT_EMAIL." >> "$GITHUB_STEP_SUMMARY" + - name: Fail when preview is broken + if: always() && env.DEPLOY_PREVIEW == 'true' && (steps.preview_build.outcome == 'failure' || steps.preview_smoke.outcome == 'failure' || steps.preview_deploy.outcome == 'failure') + run: | + echo "Portfolio preview failed, so auto-merge is blocked." >> "$GITHUB_STEP_SUMMARY" + exit 1 + - name: Auto-merge low-risk project update - if: env.AUTO_MERGE == 'true' && steps.apply.outputs.risk == 'low' && steps.pr.outputs.pr_url != '' + if: env.AUTO_MERGE == 'true' && steps.apply.outputs.risk == 'low' && steps.pr.outputs.pr_url != '' && (env.DEPLOY_PREVIEW != 'true' || (steps.preview_smoke.outcome == 'success' && steps.preview_deploy.outcome == 'success')) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diff --git a/README.md b/README.md index 6ed291c..d53cd90 100644 --- a/README.md +++ b/README.md @@ -37,22 +37,24 @@ flowchart TD This repository uses three safety layers: - `Portfolio V2 CI`: builds `portfolio-v2` on pull requests without deploying. -- `Deploy Portfolio Preview`: manually builds a branch and publishes only the static `dist/` output to a separate preview repository. +- `Sync Portfolio Project with GitHub Models`: creates a project PR, dispatches CI, and can deploy the preview in one run. +- `Deploy Portfolio Preview`: manually rebuilds a branch preview when needed. - `Deploy Portfolio V2 to GitHub Pages`: deploys the live site only from `main`. Generated portfolio sync PRs are created by `github-actions[bot]`. Because GitHub does not fire normal `pull_request` workflows from `GITHUB_TOKEN`-created events, the sync workflow explicitly dispatches `Portfolio V2 CI` for the generated branch after it opens the PR. ```mermaid flowchart TD - A["Feature branch"] --> B["Portfolio V2 CI"] - M["Generated project PR"] --> N["Sync workflow dispatches CI"] + S["Run AI project sync"] --> M["Generated project PR"] + M --> N["Sync workflow dispatches CI"] N --> B + A["Feature branch"] --> B["Portfolio V2 CI"] B --> C{"Build passes"} C -->|No| D["Fix before merge"] - C -->|Yes| E["Optional manual preview deploy"] + C -->|Yes| E["Automatic or manual preview deploy"] E --> F["Build with preview base path"] F --> G["Push dist to preview repo"] - G --> H["Preview Pages URL"] + G --> H["Preview link on PR"] H --> I{"Looks good"} I -->|No| D I -->|Yes| J["Merge to main"] @@ -69,7 +71,9 @@ Source repo: vikramsh2002/My-Portfolio Preview repo: vikramsh2002/My-Portfolio-Preview ``` -The source code stays in `My-Portfolio`. The preview workflow builds the selected branch, then pushes only `portfolio-v2/dist` into the preview repository's Pages branch. +The source code stays in `My-Portfolio`. Preview deployment builds the selected branch, then pushes only `portfolio-v2/dist` into the preview repository's Pages branch. + +The AI sync workflow can deploy preview automatically after it opens a PR. The manual preview workflow remains available when you want to rebuild a preview for an existing branch. This keeps the preview URL separate from the live portfolio: diff --git a/portfolio-v2/docs/portfolio-sync.md b/portfolio-v2/docs/portfolio-sync.md index 838bfa5..dce1508 100644 --- a/portfolio-v2/docs/portfolio-sync.md +++ b/portfolio-v2/docs/portfolio-sync.md @@ -15,7 +15,8 @@ Make sure GitHub Models is enabled for the repository first. 5. Paste the LinkedIn/profile update text. Include source, demo, live, or LinkedIn URLs if available. 6. Keep the default model or enter another GitHub Models id. 7. Optionally paste a `card_image_url` if the project does not have a live website that can be screenshotted. -8. Leave `auto_merge` off unless you want low-risk updates merged automatically. +8. Keep `deploy_preview` on so the generated PR gets a preview deployment automatically. +9. Leave `auto_merge` off unless you want low-risk updates merged automatically after CI and preview checks are safe. ## Automatic Trigger @@ -32,6 +33,11 @@ curl --request POST \ "linkedin_update": "Built and shipped a new project...", "model": "openai/gpt-4o", "auto_merge": false, + "deploy_preview": true, + "preview_repository": "vikramsh2002/My-Portfolio-Preview", + "preview_branch": "gh-pages", + "preview_base": "/My-Portfolio-Preview/", + "preview_url": "https://vikramsh2002.github.io/My-Portfolio-Preview/", "card_image_url": "https://example.com/project-card.png" } }' @@ -45,13 +51,16 @@ The workflow: - Sends the current projects and pasted update to GitHub Models. - Converts the update into structured project data. +- Updates an existing project instead of creating a duplicate when the title, a similar title, or a project URL matches an existing card. - Prepares a project thumbnail by reusing an existing local image, downloading `card_image_url`, or capturing a screenshot from the live/demo URL. - Applies an add/update to the project list. - Places newly added projects in the featured/top project row by default unless the update explicitly says not to feature them. - Runs the Vite build. - Opens a pull request with a summary when the update is safe enough for review. - Dispatches `Portfolio V2 CI` for the generated PR branch so required checks run for bot-created PRs. -- Optionally enables auto-merge for low-risk updates after the build passes. +- Builds and deploys the generated branch to the preview repository when `deploy_preview` is enabled. +- Comments the preview link on the generated PR. +- Optionally enables auto-merge for low-risk updates after CI and preview deployment are safe. - Creates a GitHub issue when the update is high-risk, unclear, missing a usable project image, or the build fails. - Sends a review email when email secrets are configured. @@ -73,7 +82,9 @@ The workflow treats dependency, workflow, secret, layout, and broad code changes ## Preview Before Merge -For generated project PRs, use the `Deploy Portfolio Preview` workflow before merging when you want to see the exact rendered UI on a separate Pages URL. +Generated project PRs can deploy preview automatically from the sync workflow. Keep `deploy_preview=true` for the normal one-click path. + +You can still use the manual `Deploy Portfolio Preview` workflow when you want to rebuild a preview for an existing branch without rerunning AI sync. The preview workflow: diff --git a/portfolio-v2/scripts/applyPortfolioProjectUpdate.mjs b/portfolio-v2/scripts/applyPortfolioProjectUpdate.mjs index 509c17a..1e0c174 100644 --- a/portfolio-v2/scripts/applyPortfolioProjectUpdate.mjs +++ b/portfolio-v2/scripts/applyPortfolioProjectUpdate.mjs @@ -28,14 +28,17 @@ const featurePolicy = normalizeFeaturePolicy(change.featurePolicy); const reviewNotes = Array.isArray(change.reviewNotes) ? change.reviewNotes.filter(Boolean) : []; let action = "none"; let changed = false; +let matchedProjectTitle = ""; let updatedProjects = currentProjects; if (normalizedIntent === "add_project" || normalizedIntent === "update_project") { - const existingIndex = findProjectIndex(currentProjects, change.matchTitle || project.title); + const existingMatch = findExistingProject(currentProjects, change.matchTitle, project); + const existingIndex = existingMatch.index; if (existingIndex >= 0) { const existingProject = currentProjects[existingIndex]; const projectPatch = compactProjectPatch(project); + matchedProjectTitle = existingProject.title; updatedProjects = [...currentProjects]; updatedProjects[existingIndex] = { ...existingProject, @@ -47,6 +50,14 @@ if (normalizedIntent === "add_project" || normalizedIntent === "update_project") }; action = "updated"; changed = true; + if (normalizedIntent === "add_project") { + reviewNotes.unshift( + `The update looked like a new project, but matched existing project "${existingProject.title}" by ${existingMatch.reason}. Updated the existing card instead.` + ); + } + } else if (normalizedIntent === "update_project") { + action = "needs_review"; + reviewNotes.unshift("The update asked to modify an existing project, but no matching portfolio project was found."); } else if (!project.title || !project.description) { action = "needs_review"; reviewNotes.unshift("The model did not provide enough detail to create a new portfolio project safely."); @@ -74,6 +85,7 @@ writeSummary({ risk, reason: String(change.reason || "").trim(), projectTitle: project.title, + matchedProjectTitle, reviewNotes, changed, }); @@ -82,6 +94,8 @@ writeOutput("action", action); writeOutput("intent", normalizedIntent); writeOutput("risk", risk); writeOutput("changed", String(changed)); +writeOutput("project_title", project.title); +writeOutput("matched_project_title", matchedProjectTitle); writeOutput("summary_path", summaryPath); function parseJsonResponse(text) { @@ -189,14 +203,39 @@ function clean(value) { return String(value || "").replace(/\s+/g, " ").trim(); } -function findProjectIndex(projects, title) { - const target = slug(title); +function findExistingProject(projects, matchTitle, project) { + const titleCandidates = [matchTitle, project.title].map(clean).filter(Boolean); + + for (const title of titleCandidates) { + const target = slug(title); + if (!target) { + continue; + } + + const index = projects.findIndex((existingProject) => slug(existingProject.title) === target); + if (index >= 0) { + return { index, reason: "exact title" }; + } + } - if (!target) { - return -1; + const projectLinks = new Set(project.links.map((link) => normalizeHref(link.href)).filter(Boolean)); + if (projectLinks.size) { + const index = projects.findIndex((existingProject) => + normalizeLinks(existingProject.links).some((link) => projectLinks.has(normalizeHref(link.href))) + ); + if (index >= 0) { + return { index, reason: "matching URL" }; + } } - return projects.findIndex((project) => slug(project.title) === target); + if (project.title) { + const index = projects.findIndex((existingProject) => areSimilarTitles(project.title, existingProject.title)); + if (index >= 0) { + return { index, reason: "similar title" }; + } + } + + return { index: -1, reason: "" }; } function slug(value) { @@ -206,7 +245,70 @@ function slug(value) { .replace(/^-+|-+$/g, ""); } -function writeSummary({ action, intent, risk, reason, projectTitle, reviewNotes, changed }) { +function normalizeHref(href) { + const cleaned = clean(href); + if (!cleaned) { + return ""; + } + + try { + const url = new URL(cleaned); + url.hash = ""; + url.searchParams.sort(); + return url.toString().replace(/\/$/, "").toLowerCase(); + } catch { + return cleaned.replace(/\/$/, "").toLowerCase(); + } +} + +function areSimilarTitles(left, right) { + const leftSlug = slug(left); + const rightSlug = slug(right); + + if (!leftSlug || !rightSlug) { + return false; + } + + const shorter = leftSlug.length <= rightSlug.length ? leftSlug : rightSlug; + const longer = leftSlug.length > rightSlug.length ? leftSlug : rightSlug; + if (shorter.length >= 12 && longer.includes(shorter)) { + return true; + } + + const leftTokens = titleTokens(left); + const rightTokens = titleTokens(right); + if (!leftTokens.length || !rightTokens.length) { + return false; + } + + const intersectionCount = leftTokens.filter((token) => rightTokens.includes(token)).length; + const unionCount = new Set([...leftTokens, ...rightTokens]).size; + const containment = intersectionCount / Math.min(leftTokens.length, rightTokens.length); + const jaccard = intersectionCount / unionCount; + + return containment >= 0.8 || jaccard >= 0.75; +} + +function titleTokens(value) { + const genericTokens = new Set([ + "vrl", + "app", + "application", + "project", + "system", + "web", + "website", + "engine", + "tool", + "platform", + ]); + + return slug(value) + .split("-") + .filter((token) => token.length > 1 && !genericTokens.has(token)); +} + +function writeSummary({ action, intent, risk, reason, projectTitle, matchedProjectTitle, reviewNotes, changed }) { const lines = [ "# Portfolio Project Sync", "", @@ -215,6 +317,7 @@ function writeSummary({ action, intent, risk, reason, projectTitle, reviewNotes, `- Risk: ${risk}`, `- Changed portfolio data: ${changed ? "yes" : "no"}`, projectTitle ? `- Project: ${projectTitle}` : "", + matchedProjectTitle ? `- Matched existing project: ${matchedProjectTitle}` : "", reason ? `- Reason: ${reason}` : "", "", ].filter(Boolean);