diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 427cf5510e4..6a7f7ce41be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: - 'linux-release-*' tags: - 'release-*.*.*-linux*' + - 'release-*.*.*-test*' pull_request: branches: - linux @@ -194,20 +195,6 @@ jobs: - name: Package application run: yarn run package if: ${{ matrix.friendlyName == 'Ubuntu' }} - - name: Create Release - uses: softprops/action-gh-release@v1 - if: - ${{ matrix.friendlyName == 'Ubuntu' && startsWith(github.ref, - 'refs/tags/') }} - with: - files: | - dist/*.AppImage - dist/*.deb - dist/*.rpm - dist/*.txt - draft: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload output artifacts uses: actions/upload-artifact@v3 if: matrix.friendlyName == 'Ubuntu' @@ -217,4 +204,69 @@ jobs: dist/*.AppImage dist/*.deb dist/*.rpm + dist/*.sha256 retention-days: 5 + + publish: + name: Create GitHub release + needs: [build, lint] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: write + steps: + - uses: actions/checkout@v3 + + - name: Use Node.js 18.14.0 + uses: actions/setup-node@v3 + with: + node-version: 18.14.0 + cache: yarn + + - name: Download all artifacts + uses: actions/download-artifact@v2 + with: + path: './artifacts' + + - name: Display structure of downloaded files + run: ls -R + working-directory: './artifacts' + + - name: Get tag name without prefix + run: | + RELEASE_TAG=${GITHUB_REF/refs\/tags\//} + echo "RELEASE_TAG=${RELEASE_TAG}" >> $GITHUB_ENV + tagNameWithoutPrefix="${RELEASE_TAG:1}" + echo "RELEASE_TAG_WITHOUT_PREFIX=${tagNameWithoutPrefix}" >> $GITHUB_ENV + + # TODO: generate release notes + # - pull in default if version matches X.Y.Z-linux1 + # - otherwise stub template + + - name: Generate release notes + run: | + node -v + yarn + node -r ts-node/register script/generate-release-notes.ts "${{ github.workspace }}/artifacts" "${{ env.RELEASE_TAG_WITHOUT_PREFIX }}" + RELEASE_NOTES_FILE=script/release_notes.txt + if [[ ! -f "$RELEASE_NOTES_FILE" ]]; then + echo "$RELEASE_NOTES_FILE does not exist. Something might have gone wrong while generating the release notes." + exit 1 + fi + echo "Release notes:" + echo "---" + cat ${RELEASE_NOTES_FILE} + echo "---" + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + name: GitHub Desktop for Linux ${{ env.RELEASE_TAG_WITHOUT_PREFIX }} + body_path: script/release_notes.txt + files: | + artifacts/*.AppImage + artifacts/*.deb + artifacts/*.rpm + draft: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 91bdd56682f..5b9cda8e24e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ vendor/windows-argv-parser/build/ junit*.xml *.swp tslint-rules/ +script/release_notes.txt diff --git a/script/generate-release-notes.ts b/script/generate-release-notes.ts new file mode 100644 index 00000000000..edbdce15954 --- /dev/null +++ b/script/generate-release-notes.ts @@ -0,0 +1,304 @@ +/* eslint-disable no-sync */ + +const glob = require('glob') +const { basename, dirname, join } = require('path') +const fs = require('fs') + +type ChecksumEntry = { filename: string; checksum: string } + +type ChecksumGroups = Record<'x64' | 'arm' | 'arm64', Array> + +type ReleaseNotesGroupType = 'new' | 'added' | 'fixed' | 'improved' | 'removed' + +type ReleaseNotesGroups = Record> + +type ReleaseNoteEntry = { + text: string + ids: Array + contributor?: string +} + +// 3 architectures * 3 package formats * 2 files (package + checksum file) +const SUCCESSFUL_RELEASE_FILE_COUNT = 3 * 3 * 2 + +const Glob = glob.GlobSync + +const args = process.argv.slice(2) +const artifactsDir = args[0] + +if (!artifactsDir) { + console.error( + `🔴 First parameter with artifacts directory not found. Aborting...` + ) + process.exit(1) +} + +const releaseTagWithoutPrefix = args[1] +if (!releaseTagWithoutPrefix) { + console.error(`🔴 Second parameter with release tag not found. Aborting...`) + process.exit(1) +} + +console.log( + `Preparing release notes for release tag ${releaseTagWithoutPrefix}` +) + +const files = new Glob(artifactsDir + '/**/*', { nodir: true }) + +let countFiles = 0 +const shaEntries = new Array() + +for (const file of files.found) { + if (file.endsWith('.sha256')) { + shaEntries.push(getShaContents(file)) + } + + countFiles++ +} + +if (SUCCESSFUL_RELEASE_FILE_COUNT !== countFiles) { + console.error( + `🔴 Artifacts folder has ${countFiles} assets, expecting ${SUCCESSFUL_RELEASE_FILE_COUNT}. Please check the GH Actions artifacts to see which are missing.` + ) + process.exit(1) +} + +const shaEntriesByArchitecture: ChecksumGroups = { + x64: shaEntries.filter( + e => + e.filename.includes('-linux-x86_64-') || + e.filename.includes('-linux-amd64-') + ), + arm: shaEntries.filter( + e => + e.filename.includes('-linux-armv7l-') || + e.filename.includes('-linux-armhf-') + ), + arm64: shaEntries.filter( + e => + e.filename.includes('-linux-aarch64-') || + e.filename.includes('-linux-arm64-') + ), +} + +console.log(`Found ${countFiles} files in artifacts directory`) +console.log(shaEntriesByArchitecture) + +const releaseNotesByGroup = getReleaseGroups(releaseTagWithoutPrefix) + +const draftReleaseNotes = generateDraftReleaseNotes( + releaseNotesByGroup, + shaEntriesByArchitecture +) +const releaseNotesPath = join(__dirname, 'release_notes.txt') + +fs.writeFileSync(releaseNotesPath, draftReleaseNotes, { encoding: 'utf8' }) + +console.log( + `✅ All done! The release notes have been written to ${releaseNotesPath}` +) + +/** + * Returns the filename (excluding .sha256) and its contents (a SHA256 checksum). + */ +function getShaContents(filePath: string): { + filename: string + checksum: string +} { + const filename = basename(filePath).slice(0, -7) + const checksum = fs.readFileSync(filePath, 'utf8') + + return { filename, checksum } +} + +function extractIds(str: string): Array { + const idRegex = /#(\d+)/g + + const idArray = new Array() + let match + + while ((match = idRegex.exec(str))) { + const textValue = match[1].trim() + const numValue = parseInt(textValue, 10) + if (!isNaN(numValue)) { + idArray.push(numValue) + } + } + + return idArray +} + +function parseCategory(str: string): ReleaseNotesGroupType | null { + const input = str.toLocaleLowerCase() + switch (input) { + case 'added': + case 'fixed': + case 'improved': + case 'new': + case 'removed': + return input + default: + return null + } +} + +function isInitialTag(tag: string): boolean { + return tag.endsWith('-linux1') || tag.endsWith('-test1') +} + +function getVersionWithoutSuffix(tag: string): string { + return tag.replace('-linux1', '').replace('-test1', '') +} + +function getReleaseGroups(version: string): ReleaseNotesGroups { + if (!isInitialTag(version)) { + return { + new: [], + added: [], + fixed: [], + improved: [], + removed: [], + } + } + + const upstreamVersion = getVersionWithoutSuffix(version) + const rootDir = dirname(__dirname) + const changelogFile = fs.readFileSync(join(rootDir, 'changelog.json')) + const changelogJson = JSON.parse(changelogFile) + const releases = changelogJson['releases'] + const changelogForVersion: Array | undefined = + releases[upstreamVersion] + + if (!changelogForVersion) { + console.error( + `🔴 Changelog version ${upstreamVersion} not found in changelog.json, which is required for publishing a release based off an upstream releease. Aborting...` + ) + process.exit(1) + } + + console.log(`found release notes`, changelogForVersion) + + const releaseNotesByGroup: ReleaseNotesGroups = { + new: [], + added: [], + fixed: [], + improved: [], + removed: [], + } + + const releaseEntryExternalContributor = /\[(.*)\](.*)- (.*)\. Thanks (.*)!/ + const releaseEntryRegex = /\[(.*)\](.*)- (.*)/ + + for (const entry of changelogForVersion) { + const externalMatch = releaseEntryExternalContributor.exec(entry) + if (externalMatch) { + const category = parseCategory(externalMatch[1]) + const text = externalMatch[2].trim() + const ids = extractIds(externalMatch[3]) + const contributor = externalMatch[4] + + if (!category) { + console.warn(`unable to identify category for '${entry}'`) + } else { + releaseNotesByGroup[category].push({ + text, + ids, + contributor, + }) + } + } else { + const match = releaseEntryRegex.exec(entry) + if (match) { + const category = parseCategory(match[1]) + const text = match[2].trim() + const ids = extractIds(match[3]) + if (!category) { + console.warn(`unable to identify category for '${entry}'`) + } else { + releaseNotesByGroup[category].push({ + text, + ids, + }) + } + } else { + console.warn(`release entry does not match any format: '${entry}'`) + } + } + } + + return releaseNotesByGroup +} + +function formatReleaseNote(note: ReleaseNoteEntry): string { + const idsAsUrls = note.ids + .map(id => `https://github.com/desktop/desktop/issues/${id}`) + .join(' ') + const contributorNote = note.contributor + ? `. Thanks ${note.contributor}!` + : '' + + const template = ` - ${note.text} - ${idsAsUrls}${contributorNote}` + + return template.trim() +} + +function renderSection( + name: string, + items: Array, + omitIfEmpty: boolean = true +): string { + if (items.length === 0 && omitIfEmpty) { + return '' + } + + const itemsText = + items.length === 0 ? 'TODO' : items.map(formatReleaseNote).join('\n') + + return ` +## ${name} + +${itemsText} + ` +} + +function formatEntry(e: ChecksumEntry): string { + return `${e.checksum} ${e.filename}` +} + +function renderArchitectureIfNotEmpty( + name: string, + items: Array +): string { + if (items.length === 0) { + return '' + } + + const itemsText = items.map(formatEntry).join('\n') + + return ` +## ${name} + +${itemsText}` +} + +/** + * Takes the release notes entries and the SHA entries, then merges them into the full draft release notes ✨ + */ +function generateDraftReleaseNotes( + releaseNotesGroups: ReleaseNotesGroups, + shaEntries: ChecksumGroups +): string { + const draftReleaseNotes = ` +${renderSection('New', releaseNotesGroups.new)} +${renderSection('Added', releaseNotesGroups.added)} +${renderSection('Fixed', releaseNotesGroups.fixed, false)} +${renderSection('Improved', releaseNotesGroups.improved, false)} +${renderSection('Removed', releaseNotesGroups.removed)} + +## SHA-256 checksums +${renderArchitectureIfNotEmpty('x64', shaEntries.x64)} +${renderArchitectureIfNotEmpty('ARM64', shaEntries.arm64)} +${renderArchitectureIfNotEmpty('ARM', shaEntries.arm)}` + + return draftReleaseNotes +} diff --git a/script/package.ts b/script/package.ts index b32499d40de..5c6085d4c0e 100644 --- a/script/package.ts +++ b/script/package.ts @@ -203,6 +203,9 @@ async function generateChecksums(files: Array) { for (const [fullPath, checksum] of checksums) { const fileName = path.basename(fullPath) checksumsText += `${checksum} - ${fileName}\n` + + const checksumFilePath = `${fullPath}.sha256` + await writeFile(checksumFilePath, checksum) } const checksumFile = path.join(distRoot, 'checksums.txt')