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..e52c29b2fe4 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, resolve } = 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,156 @@ 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(); + + // 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 folder ${p.replace(headPath, "")} does not have a package.json; skipping. This utility can only compare packages.`); + 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(", ")}.`); + /** @type PackageDetails */ + const details = new Map(); + + core.info(`From ${headPath}, found ${packages.length} packages matching the pattern ${patterns.join(", ")}.`); + + core.startGroup(`Packages`); + // Check the exports of the packages to determine which assets to include in the comparison + packages.forEach(async (packagePath) => { + const files = new Map(); + + /** + * Add a file to the files map + * @param {string} filePath + * @param {{ isMain: boolean, size: number, content: string }} details + * @returns {void} + */ + function addToFiles(filePath, { isMain = false, isHead = true, cwd } = {}) { + const fullPath = resolve(cwd, filePath); + // Check to see if the file exists + if (!existsSync(fullPath)) { + core.info(`[${cwd}] ${filePath} does not exist, skipping.`); + return; + } + + const stat = statSync(fullPath); + const content = stat.size > 0 && readFileSync(fullPath, "utf8"); + + let hasChanged = true; + const existingFile = files.get(filePath); + // If the content is the same, report that the file has not changed + if (existingFile) { + const altContent = existingFile.content[isHead ? 'base' : 'head']; + hasChanged = altContent !== content; + } + + core.info(`[${cwd}] ${filePath} added to files with { isMain: ${isMain}, hasChanged: ${hasChanged ? "true" : "false"}, size: ${stat.size ?? 0}}`); + files.set(filePath, { + isMain, + hasChanged, + size: { + [isHead ? 'head' : 'base']: stat.size ?? 0, + }, + content: { + [isHead ? 'head' : 'base']: content, + } + }); + } + + // Remove the headPath from the package paths for context swapping + const pkg = packagePath.replace(headPath, ""); + // Iterate over both the head and base paths to get the main and exports fields (in case the package is different in the head and base branches) + await Promise.all([headPath, basePath].map(async (rootFolder, idx) => { + const isHead = idx === 0; + const cwd = join(rootFolder, pkg); + const packageJsonPath = join(cwd, "package.json"); + // Check if the package.json file exists + if (!existsSync(packageJsonPath)) { + // If the package.json file doesn't exist, skip the package + return; + } + + // Get the main and exports fields from the package.json file + // Note: If a package.json file has both a main field and an exports field with a "." entry, the exports field takes precedence + const { main, exports } = require(packageJsonPath) ?? {}; + + // If the package.json doesn't have an exports or main field, skip the package + if (!exports && !main) { + core.warning(`${join(cwd, "package.json")} does not have an exports or main field, skipping.`); + return; + } + + // If the exports field is a string, add it to the files array if it exists + // Note: export strings cannot be glob patterns so we don't need to resolve them + if (typeof exports === "string") { + addToFiles(exports, { isMain: true, isHead, cwd }); + } + // If the exports field is an object, add each key to the files array + else if (typeof exports === "object") { + // Iterate over each key in the exports object + await Promise.all(Object.keys(exports).map(async (key) => { + // Resolve if the export content is a glob pattern + const exportGlobber = await glob.create(resolve(cwd, exports[key]), { + matchDirectories: false, + implicitDescendants: false + }); + + const resolvedPaths = await exportGlobber.glob(); + + if (resolvedPaths.length === 0) { + core.info(`No files found matching the glob pattern: ${exports[key]} at ${cwd}.`); + return; + } + + // Attempt to add each file to the files map + resolvedPaths.forEach((p) => { + addToFiles(p.replace(cwd, ""), { isMain: key === '.', isHead, cwd }); + }); + })); + } + else { + addToFiles(main, { isMain: true, isHead, cwd }); + } + + details.set(pkg, files); + })); + }); + core.endGroup(); // 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/build.yml b/.github/workflows/build.yml index b37e2f87880..2113fdb6cf7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,28 +17,37 @@ on: workflow_dispatch: inputs: system: + description: "The system to build on" required: false + type: string default: "macos-latest" node-version: + description: "The Node.js version to use" required: false + type: string default: "20" experimental: + description: "Whether to run the build in experimental mode" required: false - default: "false" + type: boolean + default: false ref: description: "The branch or tag to checkout" required: false workflow_call: inputs: system: + description: "The system to build on" required: false type: string default: "macos-latest" node-version: + description: "The Node.js version to use" required: false type: string default: "20" experimental: + description: "Whether to run the build in experimental mode" required: false type: boolean default: false diff --git a/.github/workflows/compare-results.yml b/.github/workflows/compare-results.yml index 70d02e11226..918df054033 100644 --- a/.github/workflows/compare-results.yml +++ b/.github/workflows/compare-results.yml @@ -10,6 +10,20 @@ name: Compare on: workflow_call: inputs: + system: + required: false + type: string + default: "ubuntu-latest" + node-version: + required: false + type: string + default: "20" + deploy-message: + required: false + type: string + alias: + required: false + type: string base-sha: description: The branch or tag to compare against required: false @@ -37,7 +51,7 @@ defaults: jobs: compare: name: Compare compiled assets - runs-on: ubuntu-latest + runs-on: ${{ inputs.system }} timeout-minutes: 10 needs: [fetch-build-artifacts] outputs: @@ -46,7 +60,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 1 ## --- SETUP --- ## - name: Use Node LTS version @@ -68,7 +82,7 @@ jobs: path: | .cache/yarn node_modules - key: ubuntu-latest-node20-${{ hashFiles('yarn.lock') }} + key: ${{ inputs.system }}-node${{ inputs.node-version }}-${{ hashFiles('yarn.lock') }} ## --- INSTALL --- ## # note: if cache-hit isn't needed b/c yarn will leverage the cache if it exists @@ -88,92 +102,79 @@ jobs: - name: Download build artifacts for head uses: actions/download-artifact@v4 with: - name: ubuntu-latest-node20-compiled-assets-${{ steps.derive-key.outputs.head-path }} - path: ${{ github.workspace }}/${{ steps.derive-key.outputs.head-path }} - merge-multiple: true + artifact-ids: ${{ needs.fetch-build-artifacts.outputs.head-artifact-id }} + path: ${{ github.workspace }}/${{ steps.derive-key.outputs.head-path }}/ + + - name: List files in head path + shell: bash + run: ls -R ${{ github.workspace }}/${{ steps.derive-key.outputs.head-path }}/ - name: Download build artifacts for base uses: actions/download-artifact@v4 with: - name: ubuntu-latest-node20-compiled-assets-${{ steps.derive-key.outputs.base-path }} - path: ${{ github.workspace }}/${{ steps.derive-key.outputs.base-path }} - merge-multiple: true + artifact-ids: ${{ needs.fetch-build-artifacts.outputs.base-artifact-id }} + path: ${{ github.workspace }}/${{ steps.derive-key.outputs.base-path }}/ + + - name: List files in base path + shell: bash + run: ls -R ${{ github.workspace }}/${{ steps.derive-key.outputs.base-path }}/ - name: Compare compiled output file size id: compare uses: ./.github/actions/file-diff - # uses: spectrum-tools/gh-action-file-diff@v1 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 strategy: matrix: branch: - - ${{ inputs.head-sha }} - - ${{ inputs.base-sha }} - runs-on: ubuntu-latest + - key: head + sha: ${{ inputs.head-sha }} + - key: base + sha: ${{ inputs.base-sha }} + runs-on: ${{ inputs.system }} timeout-minutes: 10 outputs: artifact-id: ${{ steps.upload.outputs.artifact-id }} steps: - - name: Set the cache key for builds - id: derive-key - shell: bash - run: | - BRANCH=${{ matrix.branch }} - BRANCH=${BRANCH//\//_} - echo "key=ubuntu-latest-node20-compiled-assets-${BRANCH}" >> "$GITHUB_OUTPUT" - - - name: Check if build artifacts already exist - uses: actions/download-artifact@v4 - id: artifact-found - continue-on-error: true - with: - name: ${{ steps.derive-key.outputs.key }} - - - name: Exit if artifact already exists - if: ${{ success() }} - shell: bash - run: exit 0 - - name: Check out code uses: actions/checkout@v4 with: - fetch-depth: 0 - ref: ${{ matrix.branch }} + fetch-depth: 1 + ref: ${{ matrix.branch.sha }} ## --- SETUP --- ## - name: Use Node LTS version @@ -194,7 +195,7 @@ jobs: path: | .cache/yarn node_modules - key: ubuntu-latest-node20-${{ hashFiles('yarn.lock') }} + key: ${{ inputs.system }}-node${{ inputs.node-version }}-${{ hashFiles('yarn.lock') }} ## --- INSTALL --- ## # note: if cache-hit isn't needed b/c yarn will leverage the cache if it exists @@ -203,34 +204,24 @@ jobs: run: yarn install --immutable ## --- BUILD --- ## - - name: Check for built assets - continue-on-error: true - id: download - uses: actions/download-artifact@v4 - with: - path: | - ${{ github.workspace }}/components/*/dist/** - ${{ github.workspace }}/tokens/dist/** - ${{ github.workspace }}/ui-icons/dist/** - name: ${{ steps.derive-key.outputs.key }} - - name: Build - if: ${{ steps.download.outcome != 'success' }} shell: bash run: yarn ci - name: Upload compiled assets - if: ${{ steps.download.outcome != 'success' }} continue-on-error: true id: upload uses: actions/upload-artifact@v4 with: path: | - ${{ github.workspace }}/components/*/dist/** - ${{ github.workspace }}/tokens/dist/** - ${{ github.workspace }}/ui-icons/dist/** - name: ${{ steps.derive-key.outputs.key }} + ${{ github.workspace }}/components/* + ${{ github.workspace }}/tokens + ${{ github.workspace }}/ui-icons # this is important, it lets us catch if the build failed silently # by alterting us that no compiled assets were generated if-no-files-found: error retention-days: 5 + + - name: Output a distinct matrix-specific artifact-id + shell: bash + run: echo "${{ matrix.branch.key }}-artifact-id=${{ steps.upload.outputs.artifact-id }}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index 9392f6f44b7..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 @@ -101,7 +97,7 @@ jobs: steps: - name: Get changed files id: changed-files - uses: step-security/changed-files@v46 + uses: step-security/changed-files@v45 with: files_yaml: | styles: @@ -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/templates/compare-listing.njk b/tasks/templates/compare-listing.njk index f47eff8126e..dad480a2b76 100644 --- a/tasks/templates/compare-listing.njk +++ b/tasks/templates/compare-listing.njk @@ -7,6 +7,8 @@ + +