From e6ad45f476c82eed2876869928c690a9239da466 Mon Sep 17 00:00:00 2001 From: Christian Holbrook Date: Tue, 28 Apr 2026 15:50:13 -0600 Subject: [PATCH 1/2] Verify that release assets exist before finishing the make releas artifacts job. And verify that release assets exist before running the publish job --- .github/workflows/make-release-artifacts.yml | 12 ++ .github/workflows/publish-release.yml | 13 +++ src/ReleaseCreator.ts | 110 ++++++++++++++++--- src/cli.ts | 11 ++ 4 files changed, 129 insertions(+), 17 deletions(-) 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..0ddcdcf 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,8 +329,7 @@ export class ReleaseCreator { body: patchNotes }); - releases = await this.listGitHubReleases(options.projectName); - draftRelease = releases.find(r => r.tag_name === `v${releaseVersion}`); + draftRelease = await this.assertSingleRelease(options.projectName, `v${releaseVersion}`); const prevReleaseVersion = ProjectManager.getPreviousVersion(releaseVersion, project.dir); const artifactName = this.getArtifactName(artifacts, this.getAssetName(project.dir, options.artifactPaths)).split('/').pop(); @@ -391,22 +386,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 +431,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 +509,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 +721,31 @@ 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 021621f..8ba6d7e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -50,6 +50,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' }); From 5ac22789a9ad14058f135df52348129c42d871ab Mon Sep 17 00:00:00 2001 From: Christian Holbrook Date: Wed, 29 Apr 2026 09:12:27 -0600 Subject: [PATCH 2/2] Fix lint error. Remove redundant release grab --- src/ReleaseCreator.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/ReleaseCreator.ts b/src/ReleaseCreator.ts index 0ddcdcf..cb7ee6b 100644 --- a/src/ReleaseCreator.ts +++ b/src/ReleaseCreator.ts @@ -329,8 +329,6 @@ export class ReleaseCreator { body: patchNotes }); - draftRelease = await this.assertSingleRelease(options.projectName, `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(); @@ -544,7 +542,7 @@ export class ReleaseCreator { ); } - logger.log(`✅ Found ${assets.length} asset(s) on release v${releaseVersion}`); + logger.log(`Found ${assets.length} asset(s) on release v${releaseVersion}`); for (const asset of assets) { logger.inLog(`- ${asset.name}`); } @@ -734,9 +732,7 @@ export class ReleaseCreator { } if (matching.length > 1) { - const links = matching.map(r => - ` - ${r.html_url} (draft=${r.draft}, assets=${r.assets?.length ?? 0})` - ).join('\n'); + 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.`