diff --git a/.changeset/clean-donkeys-explode.md b/.changeset/clean-donkeys-explode.md new file mode 100644 index 00000000000..cb1de2bd112 --- /dev/null +++ b/.changeset/clean-donkeys-explode.md @@ -0,0 +1,5 @@ +--- +"@spectrum-tools/gh-action-file-diff": major +--- + +This update optimizes and abstracts more the approach to comparing compiled assets. The input fields no focus on gathering package sources and more heavily relies on the exports field of the package.json to determine what assets should be compared. At the end of the processing and comparison, the diff assets are generated and a website built to review the updates. diff --git a/.github/actions/file-diff/README.md b/.github/actions/file-diff/README.md index 69ee1c91426..f3dd0db0ab9 100644 --- a/.github/actions/file-diff/README.md +++ b/.github/actions/file-diff/README.md @@ -8,6 +8,10 @@ A GitHub Action for comparing compiled assets between branches. **Required** Path to file or directory for file sizes analysis. +### `package-pattern` + +**Required** All packages to include in the comparison. Supports glob syntax. Should point to the package folder which contains a package.json asset. If no package.json asset, this folder will be left off the results. + ### `base-path` **Optional** Path to another directory against which to perform file comparisons. @@ -16,10 +20,6 @@ A GitHub Action for comparing compiled assets between branches. **Optional** GitHub token for accessing the GitHub API. Defaults to `${{ github.token }}`. -### `file-glob-pattern` - -**Optional** Glob pattern for selecting files to compare. Defaults to `dist/*`. - ### `comment` **Optional** If true, add a comment on the pull request with the results of the comparison. Defaults to `true`. @@ -44,9 +44,9 @@ Total size of all files for this branch in bytes. name: Compare compiled output file size uses: "spectrum-tools/gh-action-file-diff" with: - head-path: ${{ github.workspace }}/pull-request - base-path: ${{ github.workspace }}/base-branch - file-glob-pattern: | - components/*/dist/*.{css,json} - components/*/dist/themes/*.css + head-path: ${{ github.workspace }}/pull-request + base-path: ${{ github.workspace }}/base-branch + package-pattern: | + components/*/dist/*.{css,json} + components/*/dist/themes/*.css ``` diff --git a/.github/actions/file-diff/action.yml b/.github/actions/file-diff/action.yml index 66e30f6c2b3..7ebe341ff43 100644 --- a/.github/actions/file-diff/action.yml +++ b/.github/actions/file-diff/action.yml @@ -13,10 +13,9 @@ inputs: description: "GITHUB_TOKEN for the repository." required: false default: ${{ github.token }} - file-glob-pattern: - description: "Glob pattern for filtering of the files." - required: false - default: "**/dist/**" + package-pattern: + description: "Glob pattern for the package directories." + required: true comment: description: "Whether to comment on the PR." required: false diff --git a/.github/actions/file-diff/index.js b/.github/actions/file-diff/index.js index 1bb35b58e37..cf90b7cff11 100644 --- a/.github/actions/file-diff/index.js +++ b/.github/actions/file-diff/index.js @@ -11,15 +11,18 @@ * governing permissions and limitations under the License. */ -const { existsSync } = require("fs"); -const { join, sep } = require("path"); - const core = require("@actions/core"); const { - fetchFilesAndSizes, - bytesToSize, addComment, + bytesToSize, + difference, + fetchPackageDetails, + isNew, + isRemoved, + makeDataSections, + printChange, + printPercentChange, } = require("./utilities.js"); async function run() { @@ -28,7 +31,7 @@ async function run() { const token = core.getInput("token"); const headPath = core.getInput("head-path"); const basePath = core.getInput("base-path"); - const fileGlobPattern = core.getMultilineInput("file-glob-pattern", { + const packagePattern = core.getMultilineInput("package-pattern", { trimWhitespace: true, }); const shouldAddComment = core.getBooleanInput("comment") ?? true; @@ -36,74 +39,61 @@ async function run() { // --------------- End user input --------------- // --------------- Evaluate compiled assets --------------- - /** @type Map */ - const headOutput = await fetchFilesAndSizes(headPath, fileGlobPattern, { - core, - }); - /** - * If a diff path is provided, get the diff files and their sizes - * @type Map - **/ - const baseOutput = await fetchFilesAndSizes(basePath, fileGlobPattern, { + const packageDetails = await fetchPackageDetails(packagePattern, { + headPath, + basePath, core, }); + /** - * Indicates that there are files we're comparing against - * and not just reporting on the overall size of the compiled assets + * If true, indicates there are files to compare against; not + * just reporting on the overall size of the compiled assets * @type boolean */ - const hasBase = baseOutput.size > 0; + const shouldCompare = packageDetails.reduce((acc, [, files]) => { + if (acc) return acc; + return [...files.values()].some(({ size }) => size.base > 0); + }, false); + // --------------- End evaluation --------------- /** Split the data by component package */ - const { filePath, PACKAGES } = splitDataByPackage(headOutput, baseOutput); - const sections = makeTable(PACKAGES, filePath, headPath); + const sections = makeDataSections(packageDetails); /** * Calculate the total size of the minified files where applicable, use the regular size * if the minified file doesn't exist - * @param {Map} contentMap - The map of file names and their sizes + * @param {import('./utilities.js').PackageDetails} contentMap - The map of file names and their sizes + * @param {"head" | "base"} source - The source of the file size to calculate * @returns {number} - The total size of the minified files where applicable */ - const calculateMinifiedTotal = (contentMap) => [...contentMap.entries()] - .reduce((acc, [filename, size]) => { - // We don't include anything other than css files in the total size - if (!filename.endsWith(".css") || filename.endsWith(".min.css")) return acc; - - // If filename ends with *.css but not *.min.css, add the size of the minified file - if (/\.css$/.test(filename) && !/\.min\.css$/.test(filename)) { - const minified = filename.replace(/\.css$/, ".min.css"); - - // Check if the minified file exists in the headOutput - if (headOutput.has(minified)) { - const minSize = headOutput.get(minified); - if (minSize) return acc + minSize; - } - else { - // If the minified file doesn't exist, add the size of the css file - return acc + size; - } - } - return acc + size; + const calculateMinifiedTotal = (contentMap, source = "head") => + [...contentMap.values()].reduce((acc, fileMap) => { + acc + [...fileMap.entries()].reduce((acc, [filename, { size = {} } = {}]) => { + if (!filename.includes(".min.")) return acc; + return acc + Number(size[source] ?? 0); + }, 0); }, 0); - /** Calculate the total size of the pull request's assets */ - const overallHeadSize = calculateMinifiedTotal(headOutput); + const overallHeadSize = calculateMinifiedTotal(packageDetails, 'head'); /** - * Calculate the overall size of the base branch's assets - * if there is a base branch - * @type number - */ - const overallBaseSize = hasBase ? calculateMinifiedTotal(baseOutput) : 0; + * Calculate the overall size of the base branch's assets + * if there is a base branch + * @type number + */ + const overallBaseSize = shouldCompare ? calculateMinifiedTotal(packageDetails, 'base') : 0; /** * If there is a base branch, check if there is a change in the overall size, * otherwise, check if the overall size of the head branch is greater than 0 * @type boolean */ - const hasChange = overallHeadSize !== overallBaseSize; + const hasChange = [...packageDetails.values()].reduce((acc, files) => { + if (acc) return acc; + return [...files.values()].some(({ hasChanged }) => hasChanged); + } , false); /** * Report the changes in the compiled assets in a markdown format @@ -117,24 +107,27 @@ async function run() { let summaryTable = []; - if (sections.length === 0) { + if (!hasChange && sections.length === 0) { summary.push("", " 🎉 No changes detected in any packages"); - } - else { - const tableHead = ["Filename", "Head", "Minified", "Gzipped", ...(hasBase ? ["Compared to base"] : [])]; + } else { + const tableHead = [ + "Filename", + "Head", + "Minified", + "Gzipped", + ...(shouldCompare ? ["Compared to base"] : []), + ]; /** Next iterate over the components and report on the changes */ - sections.map(({ name, hasChange, mainFile, fileMap }) => { - if (!hasChange) return; - + sections.map(({ packageName, ...details }) => { /** - * Iterate over the files in the component and create a markdown table - * @param {Array} table - The markdown table accumulator - * @param {[readableFilename, { headByteSize, baseByteSize }]} - The deconstructed filemap entry - */ + * Iterate over the files in the component and create a markdown table + * @param {Array} table - The markdown table accumulator + * @param {[readableFilename, { headByteSize, baseByteSize }]} - The deconstructed filemap entry + */ const tableRows = ( table, // accumulator - [readableFilename, { headByteSize, baseByteSize }] // deconstructed filemap entry; i.e., Map = [key, { ...values }] + [readableFilename, { headByteSize, baseByteSize }], // deconstructed filemap entry; i.e., Map = [key, { ...values }] ) => { // @todo readable filename can be linked to html diff of the file? // https://github.com/adobe/spectrum-css/pull/2093/files#diff-6badd53e481452b5af234953767029ef2e364427dd84cdeed25f5778b6fca2e6 @@ -148,10 +141,10 @@ async function run() { return table; } - // @todo should there be any normalization before comparing the file names? - const isMainFile = readableFilename === mainFile; - - const gzipName = readableFilename.replace(/\.([a-z]+)$/, ".min.$1.gz"); + const gzipName = readableFilename.replace( + /\.([a-z]+)$/, + ".min.$1.gz", + ); const gzipFileRef = fileMap.get(gzipName); const minName = readableFilename.replace(/\.([a-z]+)$/, ".min.$1"); @@ -162,24 +155,23 @@ async function run() { let size, gzipSize, minSize, change, diff; if (removedOnBranch) { - size = "🚨 deleted/moved" + size = "🚨 deleted/moved"; change = `⬇ ${bytesToSize(baseByteSize)}`; if (difference(baseByteSize, headByteSize) !== 0 && !newOnBranch) { - diff = ` (${printPercentChange(headByteSize , baseByteSize)})`; + diff = ` (${printPercentChange(headByteSize, baseByteSize)})`; } - } - else { + } else { size = bytesToSize(headByteSize); if (gzipFileRef && gzipFileRef?.headByteSize) { // If the gzip file is new, prefix it's size with a "🆕" emoji if (isNew(gzipFileRef.headByteSize, gzipFileRef?.baseByteSize)) { gzipSize = `🆕 ${bytesToSize(gzipFileRef.headByteSize)}`; - } - else if (isRemoved(gzipFileRef.headByteSize, gzipFileRef?.baseByteSize)) { + } else if ( + isRemoved(gzipFileRef.headByteSize, gzipFileRef?.baseByteSize) + ) { gzipSize = "🚨 deleted/moved"; - } - else { + } else { gzipSize = bytesToSize(gzipFileRef.headByteSize); } } @@ -188,19 +180,18 @@ async function run() { // If the minSize file is new, prefix it's size with a "🆕" emoji if (isNew(minFileRef.headByteSize, minFileRef?.baseByteSize)) { minSize = `🆕 ${bytesToSize(minFileRef.headByteSize)}`; - } - else if (isRemoved(minFileRef.headByteSize, minFileRef?.baseByteSize)) { + } else if ( + isRemoved(minFileRef.headByteSize, minFileRef?.baseByteSize) + ) { minSize = "🚨 deleted/moved"; - } - else { + } else { minSize = bytesToSize(minFileRef.headByteSize); } } if (newOnBranch) { change = `🆕 ${bytesToSize(headByteSize)}`; - } - else { + } else { change = printChange(headByteSize, baseByteSize); } } @@ -210,13 +201,13 @@ async function run() { const delta = `${change}${diff ?? ""}`; - if (isMainFile) { - summaryTable.push([name, size, minSize, gzipSize, delta]); + if (isMain) { + summaryTable.push([packageName, size, minSize, gzipSize, delta]); } table.push([ // Bold the main file to help it stand out - isMainFile ? `**${readableFilename}**` : readableFilename, + isMain ? `**${readableFilename}**` : readableFilename, // If the file was removed, note it's absense with a dash; otherwise, note it's size size, minSize, @@ -229,44 +220,40 @@ async function run() { markdown.push( "", - `#### ${name}`, + `#### ${packageName}`, "", - ...[ - tableHead, - tableHead.map(() => "-"), - ].map((row) => `| ${row.join(" | ")} |`), - ...[...fileMap.entries()].reduce(tableRows, []).map((row) => `| ${row.join(" | ")} |`), + ...[tableHead, tableHead.map(() => "-")].map( + (row) => `| ${row.join(" | ")} |`, + ), + ...[...fileMap.entries()] + .reduce(tableRows, []) + .map((row) => `| ${row.join(" | ")} |`), ); }); /** Calculate the change in size [(head - base) / base = change] */ - if (hasBase) { + if (shouldCompare) { if (hasChange) { summary.push( `**Total change (Δ)**: ${printChange(overallHeadSize, overallBaseSize)} (${printPercentChange(overallHeadSize, overallBaseSize)})`, "", `Table reports on changes to a package's main file.${sections.length > 1 ? ` Other changes can be found in the collapsed Details section below.` : ""}`, - "" + "", ); - } - else if (sections.length > 1) { + } else if (sections.length > 1) { summary.push("✅ **No change in file sizes**", ""); } - } - else { - summary.push( - "No base branch to compare against.", - "" - ); + } else { + summary.push("No base branch to compare against.", ""); } // If there is more than 1 component updated, add a details/summary section to the markdown at the start of the array if (sections.length > 1) { markdown.unshift( - "", + '', "
", "File change details", - "" + "", ); markdown.push("", `
`); @@ -275,7 +262,7 @@ async function run() { if (summaryTable.length > 0) { const summaryTableHeader = ["Package", "Size", "Minified", "Gzipped"]; - if (hasBase && hasChange) summaryTableHeader.push("Δ"); + if (shouldCompare && hasChange) summaryTableHeader.push("Δ"); // Add the headings to the summary table if it contains data summaryTable.unshift( @@ -284,16 +271,19 @@ async function run() { ); // This removes the delta column if there are no changes to compare - summary.push(...summaryTable.map((row) => { - if (summaryTableHeader.length === row.length) return `| ${row.join(" | ")} |`; - // If the row is not the same length as the header, strip out the extra columns - if (row.length > summaryTableHeader.length) { - return `| ${row.slice(0, summaryTableHeader.length).join(" | ")} |`; - } + summary.push( + ...summaryTable.map((row) => { + if (summaryTableHeader.length === row.length) + return `| ${row.join(" | ")} |`; + // If the row is not the same length as the header, strip out the extra columns + if (row.length > summaryTableHeader.length) { + return `| ${row.slice(0, summaryTableHeader.length).join(" | ")} |`; + } - // If the row is shorter than the header, add empty columns to the end with " - " - return `| ${row.concat(Array(summaryTableHeader.length - row.length).fill(" - ")).join(" | ")} |`; - })); + // If the row is shorter than the header, add empty columns to the end with " - " + return `| ${row.concat(Array(summaryTableHeader.length - row.length).fill(" - ")).join(" | ")} |`; + }), + ); } markdown.push( @@ -301,7 +291,7 @@ async function run() { "", "* Size is the sum of all main files for packages in the library.
", "* An ASCII character in UTF-8 is 8 bits or 1 byte.", - "
" + "", ); // --------------- Start Comment --------------- @@ -309,7 +299,7 @@ async function run() { await addComment({ search: new RegExp(`^${commentHeader}`), content: [commentHeader, summary.join("\n"), markdown.join("\n")].join( - "\n\n" + "\n\n", ), token, }); @@ -328,25 +318,14 @@ async function run() { if (headOutput.size > 0) { const headMainSize = [...headOutput.entries()].reduce( (acc, [, size]) => acc + size, - 0 + 0, ); core.setOutput("total-size", headMainSize); - - if (hasBase) { - const baseMainSize = [...baseOutput.entries()].reduce( - (acc, [, size]) => acc + size, - 0 - ); - - core.setOutput( - "has-changed", - hasBase && headMainSize !== baseMainSize ? "true" : "false" - ); - } - } - else { + } else { core.setOutput("total-size", 0); } + + core.setOutput("has-changed", sections.length === 0 ? "true" : "false"); } catch (error) { core.error(error.stack); core.setFailed(error.message); @@ -354,182 +333,3 @@ async function run() { } run(); - -/** A few helpful utility functions; v1 == PR (change); v0 == base (initial) */ -const difference = (v1, v0) => v1 - v0; -const isRemoved = (v1, v0) => (!v1 || v1 === 0) && (v0 && v0 > 0); -const isNew = (v1, v0) => (v1 && v1 > 0) && (!v0 || v0 === 0); - -/** - * Convert the provided difference between file sizes into a human - * readable representation of the change. - * @param {number} difference - * @returns {string} - */ -const printChange = function (v1, v0) { - /** Calculate the change in size: v1 - v0 = change */ - const d = difference(v1, v0); - return d === 0 - ? "-" - : `${d > 0 ? "🔴 ⬆" : "🟢 ⬇"} ${bytesToSize(Math.abs(d))}`; -}; - -/** - * Convert the provided difference between file sizes into a percent - * value of the change. - * @param {number} delta - * @param {number} original - * @returns {string} - */ -const printPercentChange = function (v1, v0) { - const delta = ((v1 - v0) / v0) * 100; - if (delta === 0) return "no change"; - return `${delta.toFixed(2)}%`; -}; - -/** - * @typedef {string} PackageName - The name of the component package - * @typedef {string} FileName - The name of the file in the component package - * @typedef {{ headByteSize: number, baseByteSize: number }} FileSpecs - The size of the file in the head and base branches - * @typedef {Map} FileDetails - A map of file sizes from the head and base branches keyed by filename (full path, not shorthand) - * @typedef {{ name: PackageName, filePath: string, hasChange: boolean, mainFile: string, fileMap: FileDetails}} PackageDetails - The details of the component package including the main file and the file map as well as other short-hand properties for reporting - */ - -/** - * From the data indexed by filename, create a detailed table of the changes in the compiled assets - * with a full view of all files present in the head and base branches. - * @param {Map} PACKAGES - * @param {string} filePath - The path to the component's dist folder from the root of the repo - * @param {string} rootPath - The path from the github workspace to the root of the repo - * @returns {PackageDetails[]} - */ -const makeTable = function (PACKAGES, filePath, rootPath) { - const sections = []; - - /** Next convert that component data into a detailed object for reporting */ - PACKAGES.forEach((fileMap, packageName) => { - // Read in the main asset file from the package.json - const packagePath = join(rootPath, filePath, packageName, "package.json"); - - // Default to the index.css file if no main file is provided in the package.json - let mainFile = "index.css"; - if (existsSync(packagePath)) { - // If the package.json exists, read in the main file - const { main } = require(packagePath) ?? {}; - // If the main file is a string, use it as the main file - if (typeof main === "string") { - // Strip out the path to the dist folder from the main file - mainFile = main.replace(new RegExp("^.*\/?dist\/"), ""); - } - } - - /** - * Check if any of the files in the component have changed - * @type boolean - */ - const hasChange = fileMap.size > 0 && [...fileMap.values()].some(({ headByteSize, baseByteSize }) => headByteSize !== baseByteSize); - - /** - * We don't need to report on components that haven't changed unless they're new or removed - */ - if (!hasChange) return; - - sections.push({ - name: packageName, - filePath, - hasChange, - mainFile, - fileMap - }); - }); - - return sections; -}; - -/** - * Split out the data indexed by filename into groups by component - * @param {Map} dataMap - A map of file names relative to the root of the repo and their sizes - * @param {Map=[new Map()]} baseMap - The map of file sizes from the base branch indexed by filename (optional) - * @returns {{ filePath: string, PACKAGES: Map}} - */ -const splitDataByPackage = function (dataMap, baseMap = new Map()) { - /** - * Path to the component's dist folder relative to the root of the repo - * @type {string|undefined} - */ - let filePath; - - const PACKAGES = new Map(); - - /** - * Determine the name of the component - * @param {string} file - The full path to the file - * @param {{ part: string|undefined, offset: number|undefined, length: number|undefined }} options - The part of the path to split on and the offset to start from - * @returns {string} - */ - const getPathPart = (file, { part, offset, length, reverse = false } = {}) => { - // If the file is not a string, return it as is - if (!file || typeof file !== "string") return file; - - // Split the file path into parts - const parts = file.split("/"); - - // Default our index to 0 - let idx = 0; - // If a part is provided, find the position of that part - if (typeof part !== "undefined") { - idx = parts.findIndex((p) => p === part); - // index is -1 if the part is not found, return the file as is - if (idx === -1) return file; - } - - // If an offset is provided, add it to the index - if (typeof offset !== "undefined") idx += offset; - - // If a length is provided, return the parts from the index to the index + length - if (typeof length !== "undefined") { - // If the length is negative, return the parts from the index + length to the index - // this captures the previous n parts before the index - if (length < 0) { - return parts.slice(idx + length, idx).join(sep); - } - - return parts.slice(idx, idx + length).join(sep); - } - - // Otherwise, return the parts from the index to the end - if (!reverse) return parts.slice(idx).join(sep); - return parts.slice(0, idx).join(sep); - }; - - const pullDataIntoPackages = (filepath, size, isHead = true) => { - const packageName = getPathPart(filepath, { part: "dist", offset: -1, length: 1 }); - // Capture the path to the component's dist folder, this doesn't include the root path from outside the repo - if (!filePath) filePath = getPathPart(filepath, { part: "dist", reverse: true }); - - // Capture the filename without the path to the dist folder - const readableFilename = getPathPart(filepath, { part: "dist", offset: 1 }); - - // If fileMap data already exists for the package, use it; otherwise, create a new map - const fileMap = PACKAGES.has(packageName) ? PACKAGES.get(packageName) : new Map(); - - // If the fileMap doesn't have the file, add it - if (!fileMap.has(readableFilename)) { - fileMap.set(readableFilename, { - headByteSize: isHead ? size : dataMap.get(filepath), - baseByteSize: isHead ? baseMap.get(filepath) : size, - }); - } - - /** Update the component's table data */ - PACKAGES.set(packageName, fileMap); - }; - - // This sets up the core data structure for the package files - [...dataMap.entries()].forEach(([file, headByteSize]) => pullDataIntoPackages(file, headByteSize, true)); - - // Look for any base files not present in the head to ensure we capture when files are deleted - [...baseMap.entries()].forEach(([file, baseByteSize]) => pullDataIntoPackages(file, baseByteSize, false)); - - return { filePath, PACKAGES }; -}; diff --git a/.github/actions/file-diff/utilities.js b/.github/actions/file-diff/utilities.js index 5f336e76b99..88334fc609e 100644 --- a/.github/actions/file-diff/utilities.js +++ b/.github/actions/file-diff/utilities.js @@ -11,12 +11,87 @@ * governing permissions and limitations under the License. */ -const { statSync, existsSync, readdirSync } = require("fs"); -const { join, relative } = require("path"); +const { statSync, readFileSync, existsSync, readdirSync } = require("fs"); +const { join, relative, extname, basename } = require("path"); const github = require("@actions/github"); const glob = require("@actions/glob"); +/** + * @typedef {string} PackageName - The name of the component package + * @typedef {string} FileName - The name of the file in the component package + * @typedef {{ isMain: boolean, hasChanged: boolean, size: { head: number, base: number }, content: { head: string | undefined, base: string | undefined } }} FileDetails - The details of the component package including the main file and the file map as well as other short-hand properties for reporting + * @typedef {Map} PackageFiles - A map of file sizes from the head and base branches keyed by filename (full path, not shorthand) + * @typedef {(FileDetails & { packageName: PackageName, filePath: FileName, fileMap: PackageFiles })[]} DataSections - An array of file details represented as objects + * @typedef {Map} PackageDetails - A map of file sizes from the head and base branches keyed by filename (full path, not shorthand) + */ + +/** A few helpful utility functions; v1 == PR (change); v0 == base (initial) */ + +/** @type {(v1: number, v0: number) => number} */ +const difference = (v1, v0) => v1 - v0; +/** @type {(v1: number, v0: number) => number} */ +const isRemoved = (v1, v0) => (!v1 || v1 === 0) && (v0 && v0 > 0); +/** @type {(v1: number, v0: number) => number} */ +const isNew = (v1, v0) => (v1 && v1 > 0) && (!v0 || v0 === 0); + +/** + * Convert the provided difference between file sizes into a human + * readable representation of the change. + * @param {number} v1 + * @param {number} v0 + * @returns {string} + */ +const printChange = function (v1, v0) { + /** Calculate the change in size: v1 - v0 = change */ + const d = difference(v1, v0); + return d === 0 + ? "-" + : `${d > 0 ? "🔴 ⬆" : "🟢 ⬇"} ${bytesToSize(Math.abs(d))}`; +}; + +/** + * Convert the provided difference between file sizes into a percent + * value of the change. + * @param {number} v1 + * @param {number} v2 + * @returns {string} The percent change in size + */ +const printPercentChange = function (v1, v0) { + const delta = ((v1 - v0) / v0) * 100; + if (delta === 0) return "no change"; + return `${delta.toFixed(2)}%`; +}; + +/** + * From the data indexed by filename, create a detailed table of the changes in the compiled assets + * with a full view of all files present in the head and base branches. + * @param {PackageDetails} packageDetails + * @returns {DataSections} + */ +const makeDataSections = function (packageDetails) { + const sections = []; + + /** Next convert that component data into a detailed object for reporting */ + for (const [packageName, fileMap] of packageDetails) { + for (const [filePath, details] of fileMap) { + /** We don't need to report on components that haven't changed unless they're new or removed */ + if (!details.hasChanged) return; + + sections.push({ + packageName, + filePath, + minified: basename(filePath) + ".min." + extname(filePath), + gZipped: basename(filePath) + ".min." + extname(filePath) + ".gz", + fileMap, + ...details, + }); + } + } + + return sections; +}; + /** * List all files in the directory to help with debugging * @param {string} path @@ -39,7 +114,7 @@ function debugEmptyDirectory(path, pattern, { core }) { if (dirent.isFile()) { const file = join(path, dirent.name); if (dirent.name.startsWith(".")) return; - core.info(`- ${relative(path, file)} | ${exports.bytesToSize(statSync(file).size)}`); + core.info(`- ${relative(path, file)} | ${bytesToSize(statSync(file).size)}`); } else if (dirent.isDirectory()) { const dir = join(path, dirent.name); if (dirent.name.startsWith(".") || dirent.name === "node_modules") return; @@ -61,7 +136,7 @@ function debugEmptyDirectory(path, pattern, { core }) { * @param {number} bytes * @returns {string} The size in human readable format */ -exports.bytesToSize = function (bytes) { +const bytesToSize = function (bytes) { if (!bytes) return "-"; if (bytes === 0) return "0"; @@ -86,7 +161,7 @@ exports.bytesToSize = function (bytes) { * @param {string} token - The GitHub token to use for authentication * @returns {Promise} */ -exports.addComment = async function ({ search, content, token }) { +const addComment = async function ({ search, content, token }) { /** * @description Set up the octokit client * @type ReturnType @@ -146,36 +221,109 @@ exports.addComment = async function ({ search, content, token }) { /** * Use the provided glob pattern to fetch the files and their sizes from the * filesystem and return a Map of the files and their sizes. - * @param {string} rootPath * @param {string[]} patterns - * @returns {Promise>} - Returns the relative path and size of the files + * @returns {Promise} - Returns the relative path and size of the files sorted by package */ -exports.fetchFilesAndSizes = async function (rootPath, patterns = [], { core }) { - if (!existsSync(rootPath)) return new Map(); +const fetchPackageDetails = async function (patterns = [], { headPath, basePath, core }) { + if (patterns.length === 0) { + core.warning(`No file pattern provided for project packages.`); + return; + } + + if (!existsSync(headPath) && !existsSync(basePath)) { + core.warning(`Neither ${headPath} no ${basePath} exist in the workspace`); + return; + } /** @type import('@actions/glob').Globber */ - const globber = await glob.create(patterns.map((f) => join(rootPath, f)).join("\n")); + const globber = await glob.create(patterns.map((f) => join(headPath, f)).join("\n"), { + implicitDescendants: false + }); /** @type Awaited> */ - const files = await globber.glob(); + let packages = await globber.glob(); + + core.info(`Found ${packages.length} packages matching the pattern ${patterns.join(", ")}.`); + + // Remove any folders that don't have a package.json file + packages = packages.filter((p) => { + // Check that every package folder has a package.json asset + if (!existsSync(join(p, "package.json"))) { + core.warning(`The package at ${p} does not have a package.json file, skipping.`); + return false; + } + + return existsSync(join(p, "package.json")); + }); // If no files are found, fail the action with a helpful message - if (files.length === 0) { - debugEmptyDirectory(rootPath, patterns, { core }); + if (packages.length === 0) { + if (headPath) debugEmptyDirectory(headPath, patterns, { core }); + if (basePath) debugEmptyDirectory(basePath, patterns, { core }); return new Map(); } - core.info(`From ${rootPath}, found ${files.length} files matching the glob pattern ${patterns.join(", ")}.`); + core.info(`From ${headPath}, found ${packages.length} packages matching the pattern ${patterns.join(", ")}.`); + + /** @type PackageDetails */ + const details = new Map(); + + // Check the exports of the packages to determine which assets to include in the comparison + for (const pkg of packages) { + const files = new Map(); + const packagePath = join(pkg, "package.json"); + const { main, exports } = require(packagePath) ?? {}; + + // If the package.json doesn't have an exports field, remove it from the array + if (!exports) { + core.warning(`The package at ${pkg} does not have an exports field in the package.json, skipping.`); + packages = packages.filter((p) => p !== pkg); + } + else { + // If the exports field is a string, add it to the files array + if (typeof exports === "string") { + const stat = statSync(join(pkg, exports)); + files.set(join(pkg, exports), { + isMain: main === exports, + size: stat.size ?? 0 + }); + } + // If the exports field is an object, add each key to the files array + else if (typeof exports === "object") { + for (const key in exports) { + core.info(`"${exports[key]}" === "${main}"`, main === exports[key]); + const headStat = statSync(join(headPath, pkg, exports[key])); + const baseStat = statSync(join(basePath, pkg, exports[key])); + + const headContent = headStat > 0 && readFileSync(join(headPath, pkg, exports[key]), "utf8"); + const baseContent = baseStat > 0 && readFileSync(join(basePath, pkg, exports[key]), "utf8"); + + files.set(join(pkg, exports[key]), { + isMain: main === exports[key], + // If the content is the same, report that the file has not changed + hasChanged: headContent === baseContent, + size: { + head: headStat.size ?? 0, + base: baseStat.size ?? 0, + }, + content: { + head: headContent, + base: baseContent, + } + }); + } + } + else { + core.warning(`The package at ${pkg} has an exports field that is not a string or object, skipping.`); + packages = packages.filter((p) => p !== pkg); + } + } + + details.set(pkg, files); + } // Fetch the files and their sizes, creates an array of arrays to be used in the table - return new Map( - files - .map((f) => { - const relativePath = relative(rootPath, f); - const stat = statSync(f); - if (!stat || stat.isDirectory()) return; - return [relativePath, stat.size]; - }) - .filter(Boolean), - ); + return details; }; + +module.exports = { addComment, bytesToSize, debugEmptyDirectory, difference, fetchPackageDetails, isNew, isRemoved, makeDataSections, printChange, printPercentChange }; diff --git a/.github/workflows/compare-results.yml b/.github/workflows/compare-results.yml index 70d02e11226..54b2863e484 100644 --- a/.github/workflows/compare-results.yml +++ b/.github/workflows/compare-results.yml @@ -10,6 +10,12 @@ name: Compare on: workflow_call: inputs: + deploy-message: + required: false + type: string + alias: + required: false + type: string base-sha: description: The branch or tag to compare against required: false @@ -106,36 +112,36 @@ jobs: with: head-path: ${{ github.workspace }}/${{ steps.derive-key.outputs.head-path }}/ base-path: ${{ github.workspace }}/${{ steps.derive-key.outputs.base-path }}/ - file-glob-pattern: | - components/*/dist/** - tokens/dist/** - ui-icons/dist/** + package-pattern: | + components/* + tokens + ui-icons token: ${{ secrets.GITHUB_TOKEN }} - - name: Generate rich diff if changes detected - id: rich-diff + - name: Generate rich diff for compiled assets if: ${{ steps.compare.outputs.has-changed }} + id: rich-diff shell: bash run: yarn compare - - name: Upload changes + ## --- DEPLOY DIFFS TO NETLIFY --- ## + - name: Deploy rich diff to Netlify + uses: nwtgck/actions-netlify@v3 if: ${{ steps.compare.outputs.has-changed }} - uses: actions/upload-artifact@v4 with: - name: rich-diff - path: | - .diff-output/index.html - .diff-output/diffs/*/*.html - components/typography/dist/index.css - components/table/dist/index.css - components/badge/dist/index.css - components/button/dist/index.css - components/card/dist/index.css - components/icon/dist/index.css - components/sidenav/dist/index.css - tokens/dist/css/index.css - node_modules/diff2html/bundles/css/diff2html.min.css - node_modules/diff2html/bundles/js/diff2html.min.js + publish-dir: .diff-output + production-branch: main + production-deploy: false + github-token: ${{ secrets.GITHUB_TOKEN }} + deploy-message: ${{ inputs.deploy-message }} + enable-pull-request-comment: true + enable-commit-comment: false + overwrites-pull-request-comment: true + alias: ${{ inputs.alias }} + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN_GH_ACTIONS_DEPLOY }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_DIFF }} + timeout-minutes: 10 fetch-build-artifacts: name: Fetch & validate build artifacts @@ -209,9 +215,9 @@ jobs: uses: actions/download-artifact@v4 with: path: | - ${{ github.workspace }}/components/*/dist/** - ${{ github.workspace }}/tokens/dist/** - ${{ github.workspace }}/ui-icons/dist/** + ${{ github.workspace }}/components/* + ${{ github.workspace }}/tokens + ${{ github.workspace }}/ui-icons name: ${{ steps.derive-key.outputs.key }} - name: Build @@ -226,9 +232,9 @@ jobs: uses: actions/upload-artifact@v4 with: path: | - ${{ github.workspace }}/components/*/dist/** - ${{ github.workspace }}/tokens/dist/** - ${{ github.workspace }}/ui-icons/dist/** + ${{ github.workspace }}/components/* + ${{ github.workspace }}/tokens + ${{ github.workspace }}/ui-icons name: ${{ steps.derive-key.outputs.key }} # this is important, it lets us catch if the build failed silently # by alterting us that no compiled assets were generated diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index 59831db7359..d5b232d83d6 100644 --- a/.github/workflows/development.yml +++ b/.github/workflows/development.yml @@ -52,15 +52,9 @@ jobs: system: - macos-latest - ubuntu-latest + # - windows-latest # todo: debug token style-dictionary failures on windows node-version: - 20 - # experimental: - # - false - # include: - # - system: windows-latest - # experimental: true - # - system: windows-latest - # experimental: true uses: ./.github/workflows/build.yml with: system: ${{ matrix.system }} @@ -76,6 +70,8 @@ jobs: if: ${{ github.event.pull_request.draft != 'true' || contains(github.event.pull_request.labels.*.name, 'run_ci') }} uses: ./.github/workflows/compare-results.yml with: + deploy-message: ${{ github.event.pull_request.title }} + alias: pr-${{ github.event.number }} base-sha: ${{ github.event.pull_request.base.ref }} head-sha: ${{ github.event.pull_request.head.ref }} secrets: inherit @@ -137,8 +133,6 @@ jobs: styles_modified_files: ${{ needs.changed_files.outputs.styles_modified_files }} eslint_added_files: ${{ needs.changed_files.outputs.eslint_added_files }} eslint_modified_files: ${{ needs.changed_files.outputs.eslint_modified_files }} - mdlint_added_files: ${{ needs.changed_files.outputs.mdlint_added_files }} - mdlint_modified_files: ${{ needs.changed_files.outputs.mdlint_modified_files }} secrets: inherit # ------------------------------------------------------------- @@ -198,7 +192,7 @@ jobs: # ------------------------------------------------------------- vrt: name: Testing - if: ${{ contains(github.event.pull_request.labels.*.name, 'run_vrt') || ((github.event.pull_request.draft != true || contains(github.event.pull_request.labels.*.name, 'run_ci')) && github.event.pull_request.mergeable == true) }} + if: contains(github.event.pull_request.labels.*.name, 'run_vrt') || ((github.event.pull_request.draft != true || contains(github.event.pull_request.labels.*.name, 'run_ci')) && github.event.pull_request.mergeable == true) uses: ./.github/workflows/vrt.yml with: skip: ${{ github.base_ref == 'spectrum-two' || contains(github.event.pull_request.labels.*.name, 'skip_vrt') }} diff --git a/.github/workflows/publish-site.yml b/.github/workflows/publish-site.yml index bd7bd9fe603..bf033fa0105 100644 --- a/.github/workflows/publish-site.yml +++ b/.github/workflows/publish-site.yml @@ -78,7 +78,6 @@ jobs: publish-dir: dist production-branch: main production-deploy: false - netlify-config-path: ./netlify.toml github-token: ${{ secrets.GITHUB_TOKEN }} deploy-message: ${{ inputs.deploy-message }} enable-pull-request-comment: true diff --git a/.prettierrc b/.prettierrc index 202b6470b07..b21d31e8ebe 100644 --- a/.prettierrc +++ b/.prettierrc @@ -7,6 +7,13 @@ "options": { "printWidth": 500 } + }, + { + "files": ".github/**/*.yml", + "options": { + "tabWidth": 4, + "useTabs": false + } } ] } diff --git a/tasks/utilities.js b/tasks/utilities.js index 14f55a58c89..ede446b5758 100644 --- a/tasks/utilities.js +++ b/tasks/utilities.js @@ -290,10 +290,16 @@ async function copy(from, to, { cwd, isDeprecated = true } = {}) { onlyFiles: true, stats: true, }); + return `${"✓".green} ${relativePrint(from, { cwd }).yellow} -> ${relativePrint(to, { cwd }).padEnd(20, " ").yellow} ${`copied ${stats.length >= 0 ? stats.length : "0"} files (${bytesToSize(stats.reduce((acc, details) => acc + details.stats.size, 0))})`.gray}`; + const stats = await fg(path.join(cwd, "components") + "/**/*", { + onlyFiles: true, + stats: true, + }); return Promise.resolve(`${"✓".green} ${relativePrint(from, { cwd }).yellow} -> ${relativePrint(to, { cwd }).padEnd(20, " ").yellow} ${`copied ${stats.length >= 0 ? stats.length : "0"} files (${bytesToSize(stats.reduce((acc, details) => acc + details.stats.size, 0))})`.gray}`); }) .catch((err) => { if (!err) return; + return `${"✗".red} ${relativePrint(from, { cwd }).yellow} could not be copied to ${relativePrint(to, { cwd }).yellow}`; return Promise.resolve(`${"✗".red} ${relativePrint(from, { cwd }).yellow} could not be copied to ${relativePrint(to, { cwd }).yellow}`); }); } @@ -310,6 +316,7 @@ async function copy(from, to, { cwd, isDeprecated = true } = {}) { ) .catch((err) => { if (!err) return; + return `${"✗".red} ${relativePrint(from, { cwd }).gray} could not be copied to ${relativePrint(to, { cwd }).yellow}`; return Promise.resolve(`${"✗".red} ${relativePrint(from, { cwd }).gray} could not be copied to ${relativePrint(to, { cwd }).yellow}`); }); }