diff --git a/.github/badges/tests.svg b/.github/badges/tests.svg index 3433011..b2979d5 100644 --- a/.github/badges/tests.svg +++ b/.github/badges/tests.svg @@ -1 +1 @@ -teststests21 passed21 passed \ No newline at end of file +teststests23 passed23 passed \ No newline at end of file diff --git a/code/+matbox/+tasks/packageToolbox.m b/code/+matbox/+tasks/packageToolbox.m index 2b57f0f..90660ba 100644 --- a/code/+matbox/+tasks/packageToolbox.m +++ b/code/+matbox/+tasks/packageToolbox.m @@ -12,6 +12,11 @@ % packageTookbox('specific', versionString) VERSIONSTRING is a string containing % the specific 3 part semantic version (i.e. "2.3.4") to use. % +% By default, a LICENSE file in the project root directory is included in +% the packaged toolbox. To control which project root files are included, +% specify a top-level "RootFilesToPackage" list in MLToolboxInfo.json, +% e.g. "RootFilesToPackage": ["LICENSE", "THIRD_PARTY_NOTICES.md"]. +% % Adapted from: https://github.com/mathworks/climatedatastore/blob/main/buildUtilities/packageToolbox.m % Todo: @@ -32,6 +37,12 @@ % Get updated version number sourceFolderPath = fullfile(projectRootDirectory, options.SourceFolderName); + + % Temporarily copy project root files (e.g. LICENSE) into the source + % folder so they are included in the packaged toolbox. + stagedFilesCleanupObj = matbox.toolbox.internal.stageRootFilesForPackaging(... + projectRootDirectory, sourceFolderPath); %#ok + try previousVersion = matbox.utility.getVersionFromContents(sourceFolderPath); catch diff --git a/code/+matbox/+toolbox/+internal/stageRootFilesForPackaging.m b/code/+matbox/+toolbox/+internal/stageRootFilesForPackaging.m new file mode 100644 index 0000000..c58a3fd --- /dev/null +++ b/code/+matbox/+toolbox/+internal/stageRootFilesForPackaging.m @@ -0,0 +1,61 @@ +function cleanupObj = stageRootFilesForPackaging(projectRootDirectory, sourceFolderPath) +% stageRootFilesForPackaging - Temporarily copy project root files into the source folder +% +% cleanupObj = stageRootFilesForPackaging(projectRootDirectory, sourceFolderPath) +% copies selected files from the project root directory into the toolbox +% source folder so that they are included in the packaged toolbox. +% ToolboxOptions only packages files located below the source folder, so +% files like LICENSE that conventionally live in the project root must be +% staged before packaging. The returned onCleanup object deletes the +% staged copies when it goes out of scope. +% +% The files to stage are read from the top-level "RootFilesToPackage" +% field of MLToolboxInfo.json. If the field is not present, the default +% is "LICENSE"; a missing default file is skipped silently, whereas a +% missing explicitly listed file triggers a warning. + + arguments + projectRootDirectory (1,1) string {mustBeFolder} + sourceFolderPath (1,1) string {mustBeFolder} + end + + [~, ~, toolboxInfo] = matbox.toolbox.readToolboxInfo(projectRootDirectory); + + if isfield(toolboxInfo, 'RootFilesToPackage') + fileNames = reshape(string(toolboxInfo.RootFilesToPackage), 1, []); + warnIfMissing = true; + else + fileNames = "LICENSE"; + warnIfMissing = false; + end + + stagedFiles = string.empty; + for fileName = fileNames + sourceFile = fullfile(projectRootDirectory, fileName); + targetFile = fullfile(sourceFolderPath, fileName); + if ~isfile(sourceFile) + if warnIfMissing + warning("MatBox:Package:RootFileNotFound", ... + 'The file "%s" is listed in "RootFilesToPackage" in MLToolboxInfo.json, but was not found in the project root directory.', ... + fileName) + end + elseif isfile(targetFile) + warning("MatBox:Package:RootFileShadowed", ... + 'The source folder already contains a file named "%s". The existing file will be packaged instead of the project root file.', ... + fileName) + else + copyfile(sourceFile, targetFile) + stagedFiles(end+1) = targetFile; %#ok + end + end + + cleanupObj = onCleanup(@() deleteStagedFiles(stagedFiles)); +end + +function deleteStagedFiles(filePaths) + for filePath = filePaths + if isfile(filePath) + delete(filePath) + end + end +end diff --git a/tools/tests/+matboxtools/+unittest/TasksTest.m b/tools/tests/+matboxtools/+unittest/TasksTest.m index a6e02f8..40073a1 100644 --- a/tools/tests/+matboxtools/+unittest/TasksTest.m +++ b/tools/tests/+matboxtools/+unittest/TasksTest.m @@ -41,8 +41,63 @@ function testPackageToolbox(testCase) rmdir(fullfile(pwd, 'releases'), 's') mkdir(fullfile(pwd, 'releases')) end - matbox.tasks.packageToolbox(pwd, "build", "", "SourceFolderName", "code") + [~, toolboxFile] = matbox.tasks.packageToolbox( ... + pwd, "build", "", "SourceFolderName", "code"); testCase.verifyTrue(isfolder(fullfile(pwd, "releases"))) + + archiveFolder = fullfile(pwd, "toolbox-archive"); + unzip(toolboxFile, archiveFolder) + packagedLicenseFile = fullfile(archiveFolder, "fsroot", "LICENSE"); + testCase.verifyTrue(isfile(packagedLicenseFile)) + testCase.verifyEqual(fileread(packagedLicenseFile), ... + fileread(fullfile(pwd, "LICENSE"))) + + % Staged copy is removed from the source folder after packaging + testCase.verifyFalse(isfile(fullfile(pwd, "code", "LICENSE"))) + end + + function testPackageToolboxWithRootFilesToPackage(testCase) + pathStr = matboxtools.projectdir(); + copyfile(pathStr, pwd); + + % Add a notices file and declare an explicit list of root files + % to package, including one file that does not exist. + matbox.utility.filewrite(fullfile(pwd, 'NOTICE.md'), 'Third party notices'); + + toolboxInfoFile = fullfile(pwd, 'tools', 'MLToolboxInfo.json'); + toolboxInfo = jsondecode(fileread(toolboxInfoFile)); + toolboxInfo.RootFilesToPackage = {'LICENSE'; 'NOTICE.md'; 'MISSING.md'}; + matbox.utility.filewrite(toolboxInfoFile, ... + jsonencode(toolboxInfo, "PrettyPrint", true)); + + [~, toolboxFile] = testCase.verifyWarning(... + @() matbox.tasks.packageToolbox(pwd, "build", "", "SourceFolderName", "code"), ... + "MatBox:Package:RootFileNotFound"); + + archiveFolder = fullfile(pwd, "toolbox-archive"); + unzip(toolboxFile, archiveFolder) + testCase.verifyTrue(isfile(fullfile(archiveFolder, "fsroot", "LICENSE"))) + testCase.verifyTrue(isfile(fullfile(archiveFolder, "fsroot", "NOTICE.md"))) + end + + function testPackageToolboxShadowedRootFile(testCase) + pathStr = matboxtools.projectdir(); + copyfile(pathStr, pwd); + + % A file with the same name in the source folder shadows the + % project root file and must not be overwritten or deleted. + shadowText = 'Shadowing license file'; + matbox.utility.filewrite(fullfile(pwd, 'code', 'LICENSE'), shadowText); + + [~, toolboxFile] = testCase.verifyWarning(... + @() matbox.tasks.packageToolbox(pwd, "build", "", "SourceFolderName", "code"), ... + "MatBox:Package:RootFileShadowed"); + + archiveFolder = fullfile(pwd, "toolbox-archive"); + unzip(toolboxFile, archiveFolder) + packagedLicenseFile = fullfile(archiveFolder, "fsroot", "LICENSE"); + testCase.verifyEqual(fileread(packagedLicenseFile), shadowText) + testCase.verifyTrue(isfile(fullfile(pwd, 'code', 'LICENSE'))) end end end