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 @@
-
\ No newline at end of file
+
\ 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