diff --git a/.github/workflows/make-release-artifacts.yml b/.github/workflows/make-release-artifacts.yml index 8ca37b6..45f4fb1 100644 --- a/.github/workflows/make-release-artifacts.yml +++ b/.github/workflows/make-release-artifacts.yml @@ -30,6 +30,9 @@ jobs: make-release-artifacts: if: ${{ github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.event.repository.full_name }} runs-on: ubuntu-latest + concurrency: + group: release-artifacts-${{ github.repository }}-${{ inputs.branch }} + cancel-in-progress: false steps: - name: Checkout caller repo uses: actions/checkout@master @@ -92,3 +95,12 @@ jobs: --projectName "${{ github.repository }}" \ --artifactPaths ${{ inputs.artifact-paths }} \ --force ${{ inputs.force }} + + - name: Verify assets uploaded + env: + GH_TOKEN: ${{ github.token }} + run: | + npx ts-node nested-ci/src/cli.ts \ + verify-release-assets \ + --ref "${{ inputs.branch }}" \ + --projectName "${{ github.repository }}" diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index bf1d540..417c1c1 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -21,6 +21,9 @@ jobs: publish-release: if: ${{ (github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.event.repository.full_name) || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest + concurrency: + group: publish-release-${{ github.repository }}-${{ inputs.ref }} + cancel-in-progress: false permissions: id-token: write contents: write @@ -63,6 +66,16 @@ jobs: --releaseType ${{ inputs.release-type }} \ --testRun' + - name: Pre-flight check - verify assets exist + env: + GH_TOKEN: ${{ github.token }} + run: | + cd nested-ci + npx ts-node src/cli.ts \ + verify-release-assets \ + --ref "${{ inputs.ref }}" \ + --projectName "${{ github.repository }}" + - name: Run publish env: GH_TOKEN: ${{ github.token }} diff --git a/src/ReleaseCreator.ts b/src/ReleaseCreator.ts index 90abdfa..cb7ee6b 100644 --- a/src/ReleaseCreator.ts +++ b/src/ReleaseCreator.ts @@ -69,8 +69,8 @@ export class ReleaseCreator { const releases = await this.listGitHubReleases(options.projectName); logger.log(`Check if a GitHub release already exists for ${releaseVersion}`); - if (releases.find(r => r.tag_name === releaseVersion)) { - utils.throwError(`Release ${releaseVersion} already exists`, options); + if (releases.find(r => r.tag_name === `v${releaseVersion}`)) { + utils.throwError(`Release v${releaseVersion} already exists`, options); } logger.log(`Check if a tag already exists for ${releaseVersion}`); @@ -176,11 +176,7 @@ export class ReleaseCreator { } logger.log(`Find the existing release ${releaseVersion}`); - let releases = await this.listGitHubReleases(options.projectName); - let draftRelease = releases.find(r => r.tag_name === `v${releaseVersion}`); - if (!draftRelease) { - throw new Error(`Release ${releaseVersion} does not exist`); - } + let draftRelease = await this.assertSingleRelease(options.projectName, `v${releaseVersion}`); logger.log(`Found release ${releaseVersion}`); logger.log(`Get all existing release assets for ${options.projectName}`); @@ -333,9 +329,6 @@ export class ReleaseCreator { body: patchNotes }); - releases = await this.listGitHubReleases(options.projectName); - draftRelease = releases.find(r => r.tag_name === `v${releaseVersion}`); - const prevReleaseVersion = ProjectManager.getPreviousVersion(releaseVersion, project.dir); const artifactName = this.getArtifactName(artifacts, this.getAssetName(project.dir, options.artifactPaths)).split('/').pop(); const duplicateArtifactName = this.getArtifactName(duplicateArtifacts, this.getAssetName(project.dir, options.artifactPaths)).split('/').pop(); @@ -391,22 +384,31 @@ export class ReleaseCreator { const releaseVersion = await this.getVersion(project.dir); logger.log(`Find the existing release ${releaseVersion}`); - const releases = await this.listGitHubReleases(options.projectName); - let draftRelease = releases.find(r => r.tag_name === `v${releaseVersion}`); + let draftRelease = await this.assertSingleRelease(options.projectName, `v${releaseVersion}`); let shouldMarkAsPublished = true; - if (draftRelease?.draft) { + if (draftRelease.draft) { logger.log(`Found release ${releaseVersion}`); - } else if (draftRelease) { + } else { shouldMarkAsPublished = false; logger.log(`Release ${releaseVersion} is not a draft`); - } else { - throw new Error(`Release ${releaseVersion} does not exist`); } if (shouldMarkAsPublished) { logger.log(`Remove draft status from release ${releaseVersion}`); - utils.executeCommand(`git tag v${releaseVersion} ${options.ref}`, { cwd: project.dir }); - utils.executeCommand(`git push origin v${releaseVersion}`, { cwd: project.dir }); + + // Check if tag already exists (for idempotent re-runs) + const tagExists = utils.executeCommandSucceeds( + `git rev-parse v${releaseVersion}`, + { cwd: project.dir } + ); + + if (!tagExists) { + utils.executeCommand(`git tag v${releaseVersion} ${options.ref}`, { cwd: project.dir }); + utils.executeCommand(`git push origin v${releaseVersion}`, { cwd: project.dir }); + } else { + logger.log(`Tag v${releaseVersion} already exists, skipping tag creation`); + } + await this.octokit.rest.repos.updateRelease({ owner: this.ORG, repo: options.projectName, @@ -427,6 +429,10 @@ export class ReleaseCreator { return result; }); + if (assets.length === 0) { + throw new Error(`Release v${releaseVersion} has no assets. Run make-release-artifacts first.`); + } + for (const asset of assets) { logger.inLog(`Release asset: ${asset.name}`); const assetResponse = await this.octokit.repos.getReleaseAsset({ @@ -501,6 +507,49 @@ export class ReleaseCreator { logger.decreaseIndent(); } + /** + * Verifies that the release has assets uploaded. + * Throws an error if no assets are found. + */ + public async verifyReleaseAssets(options: { projectName: string; ref: string }) { + logger.log(`Verify release assets...ref: ${options.ref}`); + logger.increaseIndent(); + + const project = await ProjectManager.initialize({ ...options, installDependencies: false }); + + logger.log(`Checkout the ref ${options.ref}`); + utils.executeCommand(`git checkout --quiet ${options.ref}`, { cwd: project.dir }); + + const releaseVersion = await this.getVersion(project.dir); + + logger.log(`Find the existing release ${releaseVersion}`); + const release = await this.assertSingleRelease(options.projectName, `v${releaseVersion}`); + + logger.log(`Get all existing release assets for ${options.projectName}`); + const assets = await utils.octokitPageHelper((page: number) => { + return this.octokit.repos.listReleaseAssets({ + owner: this.ORG, + repo: options.projectName, + release_id: release.id + }); + }); + + if (assets.length === 0) { + throw new Error( + `Release v${releaseVersion} has no assets!\n` + + `The make-release-artifacts workflow may not have completed.\n\n` + + `To fix: Re-run the 'Make Release Artifacts' workflow, then re-run this workflow.` + ); + } + + logger.log(`Found ${assets.length} asset(s) on release v${releaseVersion}`); + for (const asset of assets) { + logger.inLog(`- ${asset.name}`); + } + + logger.decreaseIndent(); + } + public async closeRelease(options: { projectName: string; ref: string }) { logger.log(`Close release...version`); logger.increaseIndent(); @@ -670,6 +719,29 @@ export class ReleaseCreator { return releases; } + /** + * Asserts that exactly one release exists with the given tag name. + * Throws an error if no release or multiple releases are found. + */ + private async assertSingleRelease(projectName: string, tagName: string) { + const releases = await this.listGitHubReleases(projectName); + const matching = releases.filter(r => r.tag_name === tagName); + + if (matching.length === 0) { + throw new Error(`No release found with tag ${tagName}`); + } + + if (matching.length > 1) { + const links = matching.map(r => ` - ${r.html_url} (draft=${r.draft}, assets=${r.assets?.length ?? 0})`).join('\n'); + throw new Error( + `Found ${matching.length} releases with tag ${tagName}:\n${links}\n\n` + + `Please delete the duplicates and re-run the workflow.` + ); + } + + return matching[0]; + } + private async getPullRequest(repoName: string, releaseVersion: string, state: 'open' | 'closed' = 'open') { const pullRequests = await this.octokit.rest.pulls.list({ owner: this.ORG, diff --git a/src/cli.ts b/src/cli.ts index 7b40882..bc8ef60 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -53,6 +53,17 @@ export const options = yargs process.exit(1); }); }) + .command('verify-release-assets', 'Verify that the release has assets uploaded', (builder) => { + return builder + .option('projectName', { type: 'string', description: 'The name of the project' }) + .option('ref', { type: 'string', description: 'The git ref to checkout (branch or commit)' }); + }, (argv) => { + argv = preSetup(argv); + new ReleaseCreator().verifyReleaseAssets(argv).catch(e => { + console.error(e); + process.exit(1); + }); + }) .command('close-release', 'Close GitHub release, PR, and branch', (builder) => { return builder .option('projectName', { type: 'string', description: 'The name of the project to create the release for' })