diff --git a/.github/workflows/deploy-existing-release.yml b/.github/workflows/deploy-existing-release.yml deleted file mode 100644 index 0188ce3..0000000 --- a/.github/workflows/deploy-existing-release.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Deploy Existing Release - -on: - workflow_dispatch: - -# Ensure only one instance of either this or the publish workflow is running at a time -# This ensures that we don't put production into an inconsistent state -concurrency: - group: 'prod-deployment' - -jobs: - deploy: - runs-on: ubuntu-latest - permissions: - packages: write - contents: write # Write is required to create/update releases - steps: - - name: Ensure pre-requisites for deployment are met - id: validate_deployment - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { owner, repo } = context.repo; - /* - * We load 20 releases here, it's unlikely that we will want to roll back - * to a release older than that. - */ - const PAGE_SIZE = 20; - const releases = await github.rest.repos.listReleases({ - owner, - repo, - per_page: PAGE_SIZE, - }); - - /* - * Pre-releases should not be considered for deployment here, the `publish-new-release` action - * should be used instead. - */ - const availableReleases = releases.data.filter((release) => !release.prerelease); - const targetRelease = availableReleases.find( - (release) => release.tag_name === "${{github.ref_name}}" - ); - if (!targetRelease) { - throw new Error(`No recent release found for tag: ${{github.ref_name}}`); - } - - console.log( - `Found release ${targetRelease.id}, proceeding with deployment: ${targetRelease.html_url}` - ); - return targetRelease.id; - - - name: Log in to the Container registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Deploy to Production - # To emulate deployments here we are simply shifting the latest tag to the appropriate docker image. - # In a real world scenario, you would replace this with your actual deployment steps. - run: | - docker pull ghcr.io/${{ github.repository }}:${{github.ref_name}} - docker tag ghcr.io/${{ github.repository }}:${{github.ref_name}} ghcr.io/${{ github.repository }}:latest - docker push ghcr.io/${{ github.repository }}:latest - - - name: Update latest pointer - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { owner, repo } = context.repo; - const previousLatestRelease = await github.rest.repos.getLatestRelease({ - owner, - repo, - }); - await github.rest.repos.updateRelease({ - owner, - repo, - release_id: previousLatestRelease.data.id, - make_latest: false - }); - await github.rest.repos.updateRelease({ - owner, - repo, - release_id: ${{ steps.validate_deployment.outputs.result }}, - make_latest: true - }); diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml new file mode 100644 index 0000000..d02a925 --- /dev/null +++ b/.github/workflows/deploy-release.yml @@ -0,0 +1,50 @@ +name: Deploy Existing Release + +on: + workflow_dispatch: + +# Ensure only one instance of either this is running at a time. +# This ensures that we don't put production into an inconsistent state. +concurrency: + group: 'prod-deployment' + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + packages: write + contents: write # Write is required to create/update releases + steps: + - name: Perform pre-deployment checks + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const script = require('./scripts/validate-prerelease.js'); + const result = await script({github, context, core}, ${{github.ref_name}}); + console.log('Validation complete'); + console.log(result); + return result; + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Deploy to Production + # To emulate deployments here we are simply shifting the latest tag to the appropriate docker image. + # In a real world scenario, you would replace this with your actual deployment steps. + run: | + docker pull ghcr.io/${{ github.repository }}:${{github.ref_name}} + docker tag ghcr.io/${{ github.repository }}:${{github.ref_name}} ghcr.io/${{ github.repository }}:latest + docker push ghcr.io/${{ github.repository }}:latest + + - name: Update latest pointer + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const script = require('./scripts/reconcile-release.js'); + await script({github, context, core}, ${{ steps.validate_deployment.outputs.result.releaseId }}); diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 73530f1..4a3df63 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,10 +4,10 @@ on: push: branches: ["main"] -# Ensure only one instance of the workflow is running at a time -# This helps with race conditions when upserting releases +# Ensure only one instance of the workflow is running at a time. +# This helps with race conditions when upserting releases. concurrency: - group: 'main' + group: "main" env: REGISTRY: ghcr.io @@ -42,45 +42,10 @@ jobs: build-args: | APP_VERSION=${{ env.RELEASE_VERSION }} - - name: Create tag - uses: rickstaa/action-create-tag@v1 - id: "tag_create" - with: - tag: ${{ env.RELEASE_VERSION }} - - name: Upsert pending release uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const { owner, repo } = context.repo; - /* - * We should only need to load 2 releases, as either both will be latest, or one will be latest and - * the other will be pending. We're loading a few extras here so that if we get into a weird state - * we can provide a better error. - */ - const PAGE_SIZE = 10; - const releases = await github.rest.repos.listReleases({ - owner, - repo, - per_page: PAGE_SIZE, - }); - - const pendingReleases = releases.data.filter(release => release.prerelease); - if (pendingReleases.length > 1) { - throw new Error(`Found more than one pending release: ${pendingReleases.map(release => release.tag_name).join(', ')}`); - } - if (pendingReleases.length === 1) { - console.log(`Found existing pending release: ${pendingReleases[0].tag_name}, replacing it`); - await github.rest.repos.deleteRelease({ owner, repo, release_id: pendingReleases[0].id }); - } - - const newRelease = await github.rest.repos.createRelease({ - owner, - repo, - prerelease: true, - tag_name: "${{ env.RELEASE_VERSION }}", - name: "${{ env.RELEASE_VERSION }}", - generate_release_notes: true, - }); - console.log(`Created pending release: ${newRelease.data.html_url}`); + const createPrerelease = require('./scripts/create-prerelease.js'); + await createPrerelease({github, context, core}, "${{ env.RELEASE_VERSION }}"); diff --git a/.github/workflows/publish-new-release.yml b/.github/workflows/publish-new-release.yml deleted file mode 100644 index 073c352..0000000 --- a/.github/workflows/publish-new-release.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: Publish New Release - -on: - workflow_dispatch: - -# Ensure only one instance of either this or the deploy workflow is running at a time -# This ensures that we don't put production into an inconsistent state -concurrency: - group: 'prod-deployment' - -jobs: - deploy: - runs-on: ubuntu-latest - permissions: - packages: write - contents: write # Write is required to create/update releases - steps: - - name: Ensure pre-requisites for deployment are met - id: validate_deployment - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { owner, repo } = context.repo; - /* - * We should only need to load 1 release here, as pending should be at the top of the list. - * We're loading a few extras here in case we get into a weird state. - */ - const PAGE_SIZE = 10; - const releases = await github.rest.repos.listReleases({ - owner, - repo, - per_page: PAGE_SIZE, - }); - - const pendingReleases = releases.data.filter((release) => release.prerelease); - if (pendingReleases.length > 1) { - throw new Error( - `Found more than one pending release: ${pendingReleases - .map((release) => release.tag_name) - .join(", ")}` - ); - } - - const targetRelease = pendingReleases.find( - (release) => release.tag_name === "${{github.ref_name}}" - ); - if (!targetRelease) { - throw new Error(`No pending release found for tag: ${{github.ref_name}}`); - } - - console.log( - `Found pending release, proceeding with deployment: ${targetRelease.html_url}` - ); - return targetRelease.id; - - - name: Log in to the Container registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Deploy to Production - # To emulate deployments here we are simply shifting the latest tag to the appropriate docker image. - # In a real world scenario, you would replace this with your actual deployment steps. - run: | - docker pull ghcr.io/${{ github.repository }}:${{github.ref_name}} - docker tag ghcr.io/${{ github.repository }}:${{github.ref_name}} ghcr.io/${{ github.repository }}:latest - docker push ghcr.io/${{ github.repository }}:latest - - - name: Mark release as deployed - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { owner, repo } = context.repo; - await github.rest.repos.updateRelease({ - owner, - repo, - release_id: ${{ steps.validate_deployment.outputs.result }}, - prerelease: false, - make_latest: true - }); diff --git a/.github/workflows/scripts/create-prerelease.js b/.github/workflows/scripts/create-prerelease.js new file mode 100644 index 0000000..6c83677 --- /dev/null +++ b/.github/workflows/scripts/create-prerelease.js @@ -0,0 +1,21 @@ +module.exports = async ({ github, context }, tag) => { + const { owner, repo } = context.repo; + + await github.rest.git.createRef({ + owner, + repo, + ref: `refs/tags/${tag}`, + sha: context.sha, + }); + + const newRelease = await github.rest.repos.createRelease({ + owner, + repo, + prerelease: true, + tag_name: tag, + name: tag, + generate_release_notes: true, + }); + + console.log(`Created prerelease: ${newRelease.data.html_url}`); +}; diff --git a/.github/workflows/scripts/reconcile-releases.js b/.github/workflows/scripts/reconcile-releases.js new file mode 100644 index 0000000..04e5f80 --- /dev/null +++ b/.github/workflows/scripts/reconcile-releases.js @@ -0,0 +1,89 @@ +/** The maximum number of release pages to search through to find prereleases to be cleaned up. */ +const MAX_PAGE_SEARCH = 5; + +module.exports = async ({ github, context }, targetReleaseId) => { + const { owner, repo } = context.repo; + + const targetRelease = await github.rest.repos.getRelease({ + owner, + repo, + release_id: targetReleaseId, + }); + + if (targetRelease.data.draft) { + console.log("Target is a draft release, finding prereleases to bundle up"); + + // Collect all prereleases + let prereleases = []; + const releasesIterator = github.paginate.iterator( + github.rest.repos.listReleases, + { + owner, + repo, + } + ); + while (!result.done && currentPage <= MAX_PAGE_SEARCH) { + prereleases = prereleases.concat( + result.value.data.filter((release) => release.prerelease) + ); + result = await releasesIterator.next(); + } + + // Determine which prereleases are older than the target release + let newerPreleaseCount = 0; + const olderPreleases = []; + for (const prerelease of prereleases) { + const diff = await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${prerelease.tag_name}...${targetRelease.data.tag_name}`, + }); + + if (diff.data.ahead_by > 0) { + console.log( + `Prerelease ${prerelease.tag_name} is newer than target release, skipping` + ); + newerPreleaseCount++; + continue; + } else { + console.log( + `Prerelease ${prerelease.tag_name} is older than target release, adding to cleanup list` + ); + olderPreleases.push(prerelease); + } + } + + console.log( + `Found ${olderPreleases.length} older prereleases to cleanup, ${newerPreleaseCount} newer prereleases skipped` + ); + + // Delete older prereleases + for (const olderPrerelease of olderPreleases) { + await github.rest.repos.deleteRelease({ + owner, + repo, + release_id: olderPrerelease.id, + }); + } + + console.log("Promoting draft release to production"); + // Promote draft release to production + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: "${{ steps.validate_deployment.outputs.result }}", + draft: false, + prerelease: false, + latest: true, + }); + } else { + console.log("Target is an existing release, marking release as latest"); + + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: "${{ steps.validate_deployment.outputs.result }}", + make_latest: true, + }); + } +}; diff --git a/.github/workflows/scripts/validate-release.js b/.github/workflows/scripts/validate-release.js new file mode 100644 index 0000000..75662fd --- /dev/null +++ b/.github/workflows/scripts/validate-release.js @@ -0,0 +1,70 @@ +/** The maximum number of release pages to search through to find the target release. */ +const MAX_PAGE_SEARCH = 5; + +/** Find the release with the given tag name. */ +async function findRelease(github, targetTagName) { + const releasesIterator = github.paginate.iterator( + github.rest.repos.listReleases, + { + owner, + repo, + } + ); + + let currentIter = await releasesIterator.next(); + let currentPage = 1; + while (!currentIter.done && currentPage <= MAX_PAGE_SEARCH) { + const matchingRelease = currentIter.value.data.find( + (release) => release.tag_name === targetTagName + ); + if (matchingRelease) { + return matchingRelease; + } + currentPage++; + currentIter = await releasesIterator.next(); + } + return null; +} + +module.exports = async ({ github, context }, targetReleaseTag) => { + const { owner, repo } = context.repo; + + const targetRelease = await findRelease(github, targetReleaseTag); + if (!targetRelease) { + throw new Error("No release found for tag: ${{github.ref_name}}"); + } + if (targetRelease.draft) { + throw new Error( + "Found unexpected draft release, aborting deployment due to possible race conditions" + ); + } + + if (targetRelease.prerelease) { + console.log( + `Target is a prerelease, creating draft release for approval: ${targetRelease.html_url}` + ); + + const draftRelease = await github.rest.repos.createRelease({ + owner, + repo, + tag_name: "${{github.ref_name}}", + name: "${{github.ref_name}}", + draft: true, + prerelease: false, + generate_release_notes: true, + }); + + return { + releaseId: draftRelease.id, + isExistingRelease: false, + }; + } + + console.log( + `Target is an existing release, proceeding with rollback/roll forward: ${targetRelease.html_url}` + ); + return { + releaseId: targetRelease.id, + isExistingRelease: true, + }; +};