diff --git a/.eslintignore b/.eslintignore index 4ae168a3d..22fe48629 100644 --- a/.eslintignore +++ b/.eslintignore @@ -518,8 +518,10 @@ packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.js packages/app-desktop/services/sortOrder/notesSortOrderUtils.js packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js packages/app-desktop/tools/copy7Zip.js +packages/app-desktop/tools/generateLatestArm64Yml.js +packages/app-desktop/tools/githubReleasesUtils.js +packages/app-desktop/tools/modifyReleaseAssets.js packages/app-desktop/tools/notarizeMacApp.js -packages/app-desktop/tools/renameReleaseAssets.js packages/app-desktop/utils/7zip/getPathToExecutable7Zip.js packages/app-desktop/utils/7zip/pathToBundled7Zip.js packages/app-desktop/utils/checkForUpdatesUtils.test.js diff --git a/.github/workflows/build-macos-m1.yml b/.github/workflows/build-macos-m1.yml index 5bf21a656..00978ea7f 100644 --- a/.github/workflows/build-macos-m1.yml +++ b/.github/workflows/build-macos-m1.yml @@ -80,7 +80,7 @@ jobs: echo "Building and publishing desktop application..." PYTHON_PATH=$(which python) USE_HARD_LINKS=false yarn dist --mac --arm64 - yarn renameReleaseAssets --repo="$GH_REPO" --tag="$GIT_TAG_NAME" --token="$GITHUB_TOKEN" + yarn modifyReleaseAssets --repo="$GH_REPO" --tag="$GIT_TAG_NAME" --token="$GITHUB_TOKEN" else echo "Building but *not* publishing desktop application..." diff --git a/.gitignore b/.gitignore index 17a9cd028..b2677a50f 100644 --- a/.gitignore +++ b/.gitignore @@ -495,8 +495,10 @@ packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.js packages/app-desktop/services/sortOrder/notesSortOrderUtils.js packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js packages/app-desktop/tools/copy7Zip.js +packages/app-desktop/tools/generateLatestArm64Yml.js +packages/app-desktop/tools/githubReleasesUtils.js +packages/app-desktop/tools/modifyReleaseAssets.js packages/app-desktop/tools/notarizeMacApp.js -packages/app-desktop/tools/renameReleaseAssets.js packages/app-desktop/utils/7zip/getPathToExecutable7Zip.js packages/app-desktop/utils/7zip/pathToBundled7Zip.js packages/app-desktop/utils/checkForUpdatesUtils.test.js diff --git a/packages/app-desktop/afterAllArtifactBuild.js b/packages/app-desktop/afterAllArtifactBuild.js index eb70a45fb..3e3a96ebe 100644 --- a/packages/app-desktop/afterAllArtifactBuild.js +++ b/packages/app-desktop/afterAllArtifactBuild.js @@ -2,7 +2,6 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); const sha512 = require('js-sha512'); -const crypto = require('crypto'); const distDirName = 'dist'; const distPath = path.join(__dirname, distDirName); @@ -32,87 +31,9 @@ const generateChecksumFile = () => { return sha512FilePath; }; -const generateLatestArm64Yml = () => { - if (os.platform() !== 'darwin' && process.arch !== 'arm64') { - return; - } - - const calculateHash = (filePath) => { - const fileBuffer = fs.readFileSync(filePath); - const hashSum = crypto.createHash('sha512'); - hashSum.update(fileBuffer); - return hashSum.digest('base64'); - }; - - const getFileSize = (filePath) => { - return fs.statSync(filePath).size; - }; - - const extractVersion = (filePath) => { - return path.basename(filePath).split('-')[1]; - }; - - const files = fs.readdirSync(distPath); - let dmgPath = ''; - let zipPath = ''; - for (const file of files) { - if (file.endsWith('arm64.dmg')) { - const fileRenamed = `${file.slice(0, -4)}.DMG`; // renameReleaseAssets script will rename from .dmg to .DMG - dmgPath = path.join(distPath, fileRenamed); - } else if (file.endsWith('arm64.zip')) { - zipPath = path.join(distPath, file); - } - } - const versionFromFilePath = extractVersion(zipPath); - - const info = { - version: versionFromFilePath, - dmgPath: dmgPath, - zipPath: zipPath, - releaseDate: new Date().toISOString(), - }; - - /* eslint-disable no-console */ - if (!fs.existsSync(info.dmgPath) || !fs.existsSync(info.zipPath)) { - console.error('One or both executable files do not exist:', info.dmgPath, info.zipPath); - return; - } - - console.info('Calculating hash of files...'); - const dmgHash = calculateHash(info.dmgPath); - const zipHash = calculateHash(info.zipPath); - - console.info('Calculating size of files...'); - const dmgSize = getFileSize(info.dmgPath); - const zipSize = getFileSize(info.zipPath); - - console.info('Generating content of latest-mac-arm64.yml file...'); - const yamlFilePath = path.join(distPath, 'latest-mac-arm64.yml'); - const yamlContent = `version: ${info.version} -files: - - url: ${path.basename(info.zipPath)} - sha512: ${zipHash} - size: ${zipSize} - - url: ${path.basename(info.dmgPath)} - sha512: ${dmgHash} - size: ${dmgSize} -path: ${path.basename(info.zipPath)} -sha512: ${zipHash} -releaseDate: '${info.releaseDate}' -`; - fs.writeFileSync(yamlFilePath, yamlContent); - console.log('YML file generated successfully for arm64 architecure.'); - - const fileContent = fs.readFileSync(yamlFilePath, 'utf8'); - console.log('Generated YML Content:\n', fileContent); - /* eslint-enable no-console */ - return yamlFilePath; -}; - const mainHook = () => { const sha512FilePath = generateChecksumFile(); - const lastestArm64YmlFilePath = generateLatestArm64Yml(); - const outputFiles = [sha512FilePath, lastestArm64YmlFilePath].filter(item => item); + const outputFiles = [sha512FilePath].filter(item => item); return outputFiles; }; diff --git a/packages/app-desktop/package.json b/packages/app-desktop/package.json index 4a4e0155d..9b2fe675e 100644 --- a/packages/app-desktop/package.json +++ b/packages/app-desktop/package.json @@ -15,7 +15,7 @@ "test": "jest", "test-ui": "playwright test", "test-ci": "yarn test && sh ./integration-tests/run-ci.sh", - "renameReleaseAssets": "node tools/renameReleaseAssets.js" + "modifyReleaseAssets": "node tools/modifyReleaseAssets.js" }, "repository": { "type": "git", diff --git a/packages/app-desktop/tools/generateLatestArm64Yml.ts b/packages/app-desktop/tools/generateLatestArm64Yml.ts new file mode 100644 index 000000000..ca21c193e --- /dev/null +++ b/packages/app-desktop/tools/generateLatestArm64Yml.ts @@ -0,0 +1,69 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; + +export interface GenerateInfo { + version: string; + dmgPath: string; + zipPath: string; + releaseDate: string; +} + +const calculateHash = (filePath: string): string => { + const fileBuffer = fs.readFileSync(filePath); + const hashSum = crypto.createHash('sha512'); + hashSum.update(fileBuffer); + return hashSum.digest('base64'); +}; + +const getFileSize = (filePath: string): number => { + return fs.statSync(filePath).size; +}; + +export const generateLatestArm64Yml = (info: GenerateInfo, destinationPath: string): string | undefined => { + if (!fs.existsSync(info.dmgPath) || !fs.existsSync(info.zipPath)) { + throw new Error(`One or both executable files do not exist: ${info.dmgPath}, ${info.zipPath}`); + } + if (!info.version) { + throw new Error('Version is empty'); + } + if (!destinationPath) { + throw new Error('Destination path is empty'); + } + + console.info('Calculating hash of files...'); + const dmgHash: string = calculateHash(info.dmgPath); + const zipHash: string = calculateHash(info.zipPath); + + console.info('Calculating size of files...'); + const dmgSize: number = getFileSize(info.dmgPath); + const zipSize: number = getFileSize(info.zipPath); + + console.info('Generating content of latest-mac-arm64.yml file...'); + + if (!fs.existsSync(destinationPath)) { + fs.mkdirSync(destinationPath); + } + + const yamlFilePath: string = path.join(destinationPath, 'latest-mac-arm64.yml'); + const yamlContent = `version: ${info.version} +files: + - url: ${path.basename(info.zipPath)} + sha512: ${zipHash} + size: ${zipSize} + - url: ${path.basename(info.dmgPath)} + sha512: ${dmgHash} + size: ${dmgSize} +path: ${path.basename(info.zipPath)} +sha512: ${zipHash} +releaseDate: '${info.releaseDate}' +`; + + fs.writeFileSync(yamlFilePath, yamlContent); + console.log(`YML file for version ${info.version} was generated successfully at ${destinationPath} for arm64.`); + + const fileContent: string = fs.readFileSync(yamlFilePath, 'utf8'); + console.log('Generated YML Content:\n', fileContent); + + return yamlFilePath; +}; diff --git a/packages/app-desktop/tools/githubReleasesUtils.ts b/packages/app-desktop/tools/githubReleasesUtils.ts new file mode 100644 index 000000000..f3e13ad07 --- /dev/null +++ b/packages/app-desktop/tools/githubReleasesUtils.ts @@ -0,0 +1,105 @@ +import * as fs from 'fs'; +import { createWriteStream } from 'fs'; +import * as path from 'path'; +import { promisify } from 'util'; +import { GitHubRelease, GitHubReleaseAsset } from '../utils/checkForUpdatesUtils'; + +const pipeline = promisify(require('stream').pipeline); + +export interface Context { + repo: string; // {owner}/{repo} + githubToken: string; + targetTag: string; +} + +const apiBaseUrl = 'https://api.github.com/repos/'; +const defaultApiHeaders = (context: Context) => ({ + 'Authorization': `token ${context.githubToken}`, + 'X-GitHub-Api-Version': '2022-11-28', + 'Accept': 'application/vnd.github+json', +}); + +export const getTargetRelease = async (context: Context, targetTag: string): Promise => { + console.log('Fetching releases...'); + + // Note: We need to fetch all releases, not just /releases/tag/tag-name-here. + // The latter doesn't include draft releases. + + const result = await fetch(`${apiBaseUrl}${context.repo}/releases`, { + method: 'GET', + headers: defaultApiHeaders(context), + }); + + const releases = await result.json(); + if (!result.ok) { + throw new Error(`Error fetching release: ${JSON.stringify(releases)}`); + } + + for (const release of releases) { + if (release.tag_name === targetTag) { + return release; + } + } + + throw new Error(`No release with tag ${targetTag} found!`); +}; + +// Download a file from Joplin Desktop releases +export const downloadFile = async (asset: GitHubReleaseAsset, destinationDir: string): Promise => { + const downloadPath = path.join(destinationDir, asset.name); + if (!fs.existsSync(destinationDir)) { + fs.mkdirSync(destinationDir); + } + + /* eslint-disable no-console */ + console.log(`Downloading ${asset.name} to ${downloadPath}`); + const response = await fetch(asset.browser_download_url); + if (!response.ok) { + throw new Error(`Failed to download file: Status Code ${response.status}`); + } + const fileStream = createWriteStream(downloadPath); + await pipeline(response.body, fileStream); + console.log('Download successful!'); + /* eslint-enable no-console */ + return downloadPath; +}; + +export const updateReleaseAsset = async (context: Context, assetUrl: string, newName: string) => { + console.log('Updating asset with URL', assetUrl, 'to have name, ', newName); + + // See https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#update-a-release-asset + const result = await fetch(assetUrl, { + method: 'PATCH', + headers: defaultApiHeaders(context), + body: JSON.stringify({ + name: newName, + }), + }); + + if (!result.ok) { + throw new Error(`Unable to update release asset: ${await result.text()}`); + } +}; + +export const uploadReleaseAsset = async (context: Context, release: GitHubRelease, filePath: string): Promise => { + console.log(`Uploading file from ${filePath} to release ${release.tag_name}`); + + const fileContent = fs.readFileSync(filePath); + const fileName = path.basename(filePath); + const uploadUrl = `https://uploads.github.com/repos/${context.repo}/releases/${release.id}/assets?name=${encodeURIComponent(fileName)}`; + + const response = await fetch(uploadUrl, { + method: 'POST', + headers: { + ...defaultApiHeaders(context), + 'Content-Type': 'application/octet-stream', + }, + body: fileContent, + }); + + if (!response.ok) { + throw new Error(`Failed to upload asset: ${await response.text()}`); + } else { + console.log(`${fileName} uploaded successfully.`); + } +}; diff --git a/packages/app-desktop/tools/modifyReleaseAssets.ts b/packages/app-desktop/tools/modifyReleaseAssets.ts new file mode 100644 index 000000000..d98b33f7d --- /dev/null +++ b/packages/app-desktop/tools/modifyReleaseAssets.ts @@ -0,0 +1,93 @@ + +import path = require('path'); +import { parseArgs } from 'util'; +import { Context, downloadFile, getTargetRelease, updateReleaseAsset, uploadReleaseAsset } from './githubReleasesUtils'; +import { GitHubRelease } from '../utils/checkForUpdatesUtils'; +import { GenerateInfo, generateLatestArm64Yml } from './generateLatestArm64Yml'; + +const basePath = path.join(__dirname, '..'); +const downloadDir = path.join(basePath, 'downloads'); + +// Renames release assets in Joplin Desktop releases +const renameReleaseAssets = async (context: Context, release: GitHubRelease) => { + // Patterns used to rename releases + const renamePatterns: [RegExp, string][] = [ + [/-arm64\.dmg$/, '-arm64.DMG'], + ]; + + for (const asset of release.assets) { + for (const [pattern, replacement] of renamePatterns) { + if (asset.name.match(pattern)) { + const newName = asset.name.replace(pattern, replacement); + await updateReleaseAsset(context, asset.url, newName); + + // Only rename a release once. + break; + } + } + } +}; + +// Creates release assets in Joplin Desktop releases +const createReleaseAssets = async (context: Context, release: GitHubRelease) => { + // Create latest-mac-arm64.yml file and publish + let dmgPath; + let zipPath; + for (const asset of release.assets) { + if (asset.name.endsWith('arm64.zip')) { + zipPath = await downloadFile(asset, downloadDir); + } else if (asset.name.endsWith('arm64.DMG')) { + dmgPath = await downloadFile(asset, downloadDir); + } + } + + const info: GenerateInfo = { + version: release.tag_name.slice(1), + dmgPath: dmgPath, + zipPath: zipPath, + releaseDate: new Date().toISOString(), + }; + + const latestArm64FilePath = generateLatestArm64Yml(info, downloadDir); + void uploadReleaseAsset(context, release, latestArm64FilePath); +}; + + +const modifyReleaseAssets = async () => { + const args = parseArgs({ + options: { + tag: { type: 'string' }, + token: { type: 'string' }, + repo: { type: 'string' }, + }, + }); + + if (!args.values.tag || !args.values.token || !args.values.repo) { + throw new Error([ + 'Required arguments: --tag, --token, --repo', + ' --tag should be a git tag with an associated release (e.g. v12.12.12)', + ' --token should be a GitHub API token', + ' --repo should be a string in the form user/reponame (e.g. laurent22/joplin)', + ].join('\n')); + } + + const context: Context = { + repo: args.values.repo, + githubToken: args.values.token, + targetTag: args.values.tag, + }; + + const release = await getTargetRelease(context, context.targetTag); + + if (!release.assets) { + console.log(release); + throw new Error(`Release ${release.tag_name} missing assets!`); + } + + console.log('Renaming release assets for tag', context.targetTag, context.repo); + void renameReleaseAssets(context, release); + console.log('Creating latest-mac-arm64.yml asset for tag', context.targetTag, context.repo); + void createReleaseAssets(context, release); +}; + +void modifyReleaseAssets(); diff --git a/packages/app-desktop/tools/renameReleaseAssets.ts b/packages/app-desktop/tools/renameReleaseAssets.ts deleted file mode 100644 index 743909fd8..000000000 --- a/packages/app-desktop/tools/renameReleaseAssets.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { parseArgs } from 'util'; - -interface Context { - repo: string; // {owner}/{repo} - githubToken: string; -} - -const apiBaseUrl = 'https://api.github.com/repos/'; -const defaultApiHeaders = (context: Context) => ({ - 'Authorization': `token ${context.githubToken}`, - 'X-GitHub-Api-Version': '2022-11-28', - 'Accept': 'application/vnd.github+json', -}); - -const getTargetRelease = async (context: Context, targetTag: string) => { - console.log('Fetching releases...'); - - // Note: We need to fetch all releases, not just /releases/tag/tag-name-here. - // The latter doesn't include draft releases. - - const result = await fetch(`${apiBaseUrl}${context.repo}/releases`, { - method: 'GET', - headers: defaultApiHeaders(context), - }); - - const releases = await result.json(); - if (!result.ok) { - throw new Error(`Error fetching release: ${JSON.stringify(releases)}`); - } - - for (const release of releases) { - if (release.tag_name === targetTag) { - return release; - } - } - - throw new Error(`No release with tag ${targetTag} found!`); -}; - -const updateReleaseAsset = async (context: Context, assetUrl: string, newName: string) => { - console.log('Updating asset with URL', assetUrl, 'to have name, ', newName); - - // See https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#update-a-release-asset - const result = await fetch(assetUrl, { - method: 'PATCH', - headers: defaultApiHeaders(context), - body: JSON.stringify({ - name: newName, - }), - }); - - if (!result.ok) { - throw new Error(`Unable to update release asset: ${await result.text()}`); - } -}; - -// Renames release assets in Joplin Desktop releases -const renameReleaseAssets = async () => { - const args = parseArgs({ - options: { - tag: { type: 'string' }, - token: { type: 'string' }, - repo: { type: 'string' }, - }, - }); - - if (!args.values.tag || !args.values.token || !args.values.repo) { - throw new Error([ - 'Required arguments: --tag, --token, --repo', - ' --tag should be a git tag with an associated release (e.g. v12.12.12)', - ' --token should be a GitHub API token', - ' --repo should be a string in the form user/reponame (e.g. laurent22/joplin)', - ].join('\n')); - } - - - const context: Context = { - repo: args.values.repo, - githubToken: args.values.token, - }; - - console.log('Renaming release assets for tag', args.values.tag, context.repo); - - const release = await getTargetRelease(context, args.values.tag); - - if (!release.assets) { - console.log(release); - throw new Error(`Release ${release.name} missing assets!`); - } - - // Patterns used to rename releases - const renamePatterns = [ - [/-arm64\.dmg$/, '-arm64.DMG'], - ]; - - for (const asset of release.assets) { - for (const [pattern, replacement] of renamePatterns) { - if (asset.name.match(pattern)) { - const newName = asset.name.replace(pattern, replacement); - await updateReleaseAsset(context, asset.url, newName); - - // Only rename a release once. - break; - } - } - } -}; - -void renameReleaseAssets(); diff --git a/packages/app-desktop/utils/checkForUpdatesUtils.ts b/packages/app-desktop/utils/checkForUpdatesUtils.ts index 901c764ac..71b8904b1 100644 --- a/packages/app-desktop/utils/checkForUpdatesUtils.ts +++ b/packages/app-desktop/utils/checkForUpdatesUtils.ts @@ -7,9 +7,11 @@ export interface CheckForUpdateOptions { export interface GitHubReleaseAsset { name: string; browser_download_url: string; + url?: string; } export interface GitHubRelease { + id?: string; tag_name: string; prerelease: boolean; body: string;