diff --git a/.github/workflows/release-cloud.yml b/.github/workflows/release-cloud.yml index fdb8f114901..065c807e22d 100644 --- a/.github/workflows/release-cloud.yml +++ b/.github/workflows/release-cloud.yml @@ -15,6 +15,11 @@ on: required: true default: false type: boolean + promote_to_production: + description: 'Promote the Beta-tested artifact to Production after E2E' + required: true + default: false + type: boolean reason: description: 'Optional reason for manually deploying to Beta' required: false @@ -35,6 +40,7 @@ env: # existing wire-webapp-staging Elastic Beanstalk environment and URL. BETA_ENVIRONMENT_NAME: wire-webapp-staging BETA_WEBAPP_URL: https://wire-webapp-staging.zinfra.io/ + PRODUCTION_ENVIRONMENT_NAME: wire-webapp-prod jobs: validate_request: @@ -238,10 +244,219 @@ jobs: OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} TESTINY_API_KEY: ${{ secrets.TESTINY_API_KEY }} + production_preflight: + name: Production preflight + runs-on: ubuntu-24.04 + needs: [build_artifact, create_beta_tag, run_e2e] + permissions: + contents: read + outputs: + production_preflight_result: ${{ steps.production_preflight.outputs.production_preflight_result }} + production_skipped_reason: ${{ steps.production_preflight.outputs.production_skipped_reason }} + production_tag_name: ${{ steps.production_preflight.outputs.production_tag_name }} + should_deploy: ${{ steps.production_preflight.outputs.should_deploy }} + + steps: + - name: Checkout release commit + if: ${{ inputs.promote_to_production == true }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + fetch-depth: 0 + ref: ${{ needs.build_artifact.outputs.release_commit_sha }} + + - name: Add repository Yarn wrapper to PATH + if: ${{ inputs.promote_to_production == true }} + run: echo "$GITHUB_WORKSPACE/bin" >> "$GITHUB_PATH" + + - name: Setup Node.js + if: ${{ inputs.promote_to_production == true }} + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f + with: + node-version-file: '.nvmrc' + cache: 'yarn' + + - name: Install dependencies + if: ${{ inputs.promote_to_production == true }} + run: ./bin/yarn --immutable + + - name: Check Production promotion eligibility + id: production_preflight + env: + BETA_TAG_NAME: ${{ needs.create_beta_tag.outputs.beta_tag_name }} + PROMOTE_TO_PRODUCTION: ${{ inputs.promote_to_production }} + RELEASE_COMMIT_SHA: ${{ needs.build_artifact.outputs.release_commit_sha }} + RELEASE_IDENTIFIER: ${{ needs.build_artifact.outputs.release_identifier }} + run: | + set -euo pipefail + + if [[ "${PROMOTE_TO_PRODUCTION}" != 'true' ]]; then + production_tag_name="${RELEASE_IDENTIFIER}-production" + production_skipped_reason='Production promotion was not requested' + + { + echo 'should_deploy=false' + echo 'production_preflight_result=skipped' + echo "production_skipped_reason=${production_skipped_reason}" + echo "production_tag_name=${production_tag_name}" + } >> "$GITHUB_OUTPUT" + + { + echo '## Production promotion' + echo "- Production promotion requested: false" + echo "- Production deployment skipped: true" + echo "- Skip reason: ${production_skipped_reason}" + echo "- Production tag: ${production_tag_name}" + } >> "$GITHUB_STEP_SUMMARY" + + exit 0 + fi + + git fetch --tags origin + + production_tag_name="$(./bin/yarn ts-node --project ./tsconfig.bin.json ./bin/releaseMetadataCli.ts production-tag "${RELEASE_IDENTIFIER}")" + beta_tag_commit_sha="$(git rev-parse "${BETA_TAG_NAME}^{commit}")" + + if [[ "${beta_tag_commit_sha}" != "${RELEASE_COMMIT_SHA}" ]]; then + echo "Beta tag ${BETA_TAG_NAME} points to ${beta_tag_commit_sha}, expected ${RELEASE_COMMIT_SHA}." >&2 + exit 1 + fi + + if git rev-parse --quiet --verify "refs/tags/${production_tag_name}" >/dev/null; then + production_tag_commit_sha="$(git rev-parse "${production_tag_name}^{commit}")" + + if [[ "${production_tag_commit_sha}" != "${RELEASE_COMMIT_SHA}" ]]; then + echo "Production tag ${production_tag_name} points to ${production_tag_commit_sha}, expected ${RELEASE_COMMIT_SHA}." >&2 + exit 1 + fi + + production_skipped_reason="Release is already tagged as Production with ${production_tag_name}" + + { + echo 'should_deploy=false' + echo 'production_preflight_result=already_tagged' + echo "production_skipped_reason=${production_skipped_reason}" + echo "production_tag_name=${production_tag_name}" + } >> "$GITHUB_OUTPUT" + + { + echo '## Production promotion' + echo "- Production promotion requested: true" + echo "- Production deployment skipped: true" + echo "- Skip reason: ${production_skipped_reason}" + echo "- Production tag: ${production_tag_name}" + } >> "$GITHUB_STEP_SUMMARY" + + exit 0 + fi + + { + echo 'should_deploy=true' + echo 'production_preflight_result=ready' + echo 'production_skipped_reason=' + echo "production_tag_name=${production_tag_name}" + } >> "$GITHUB_OUTPUT" + + { + echo '## Production promotion' + echo "- Production promotion requested: true" + echo "- Production deployment skipped: false" + echo "- Production tag: ${production_tag_name}" + echo "- Approval gate: ${PRODUCTION_ENVIRONMENT_NAME} GitHub Environment settings" + } >> "$GITHUB_STEP_SUMMARY" + + deploy_to_production: + name: Deploy to Production + runs-on: ubuntu-24.04 + needs: [build_artifact, production_preflight] + if: ${{ needs.production_preflight.outputs.should_deploy == 'true' }} + permissions: {} + # Production approval is enforced by the wire-webapp-prod GitHub Environment settings. + environment: wire-webapp-prod + concurrency: + group: release-cloud-production + cancel-in-progress: false + + steps: + - name: Download build artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: ${{ needs.build_artifact.outputs.artifact_name }} + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 + with: + aws-access-key-id: ${{ secrets.WEBTEAM_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.WEBTEAM_AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy to Production + id: deploy + uses: aws-actions/aws-elasticbeanstalk-deploy@1f56e4e813ae4eb167e69ca324234c336c1df573 + with: + aws-region: ${{ env.AWS_REGION }} + application-name: Webapp + environment-name: ${{ env.PRODUCTION_ENVIRONMENT_NAME }} + version-label: ${{ needs.build_artifact.outputs.release_identifier }}-${{ github.run_id }}-${{ github.run_attempt }}-production + deployment-package-path: ebs.zip + wait-for-deployment: true + wait-for-environment-recovery: true + deployment-timeout: 150 + use-existing-application-version-if-available: true + + create_production_tag: + name: Create Production tag + runs-on: ubuntu-24.04 + needs: [build_artifact, production_preflight, deploy_to_production] + if: ${{ needs.production_preflight.outputs.should_deploy == 'true' && needs.deploy_to_production.result == 'success' }} + permissions: + contents: write + outputs: + production_tag_name: ${{ steps.production_tag.outputs.production_tag_name }} + + steps: + - name: Checkout release commit + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 + ref: ${{ needs.build_artifact.outputs.release_commit_sha }} + + - name: Create and push Production tag + id: production_tag + env: + PRODUCTION_TAG_NAME: ${{ needs.production_preflight.outputs.production_tag_name }} + RELEASE_COMMIT_SHA: ${{ needs.build_artifact.outputs.release_commit_sha }} + run: | + set -euo pipefail + + git fetch --tags origin + + if git rev-parse --quiet --verify "refs/tags/${PRODUCTION_TAG_NAME}" >/dev/null; then + existing_production_tag_commit_sha="$(git rev-parse "${PRODUCTION_TAG_NAME}^{commit}")" + echo "Production tag ${PRODUCTION_TAG_NAME} already exists at ${existing_production_tag_commit_sha}." >&2 + exit 1 + fi + + git config user.email "zebot@users.noreply.github.com" + git config user.name "Zebot" + git tag --annotate "$PRODUCTION_TAG_NAME" --message "Release $PRODUCTION_TAG_NAME" "$RELEASE_COMMIT_SHA" + git push origin "refs/tags/$PRODUCTION_TAG_NAME" + + echo "production_tag_name=${PRODUCTION_TAG_NAME}" >> "$GITHUB_OUTPUT" + summarize_release: name: Summarize release runs-on: ubuntu-24.04 - needs: [build_artifact, deploy_to_beta, create_beta_tag, run_e2e] + needs: + [ + build_artifact, + deploy_to_beta, + create_beta_tag, + run_e2e, + production_preflight, + deploy_to_production, + create_production_tag, + ] if: ${{ always() }} permissions: {} @@ -253,6 +468,16 @@ jobs: BETA_TAG_NAME: ${{ needs.create_beta_tag.outputs.beta_tag_name }} E2E_REPORT_URL: ${{ needs.run_e2e.outputs.reportUrl }} E2E_RESULT: ${{ needs.run_e2e.result }} + PRODUCTION_ARTIFACT_CHECKSUM: ${{ needs.build_artifact.outputs.artifact_checksum }} + PRODUCTION_ARTIFACT_NAME: ${{ needs.build_artifact.outputs.artifact_name }} + PRODUCTION_DEPLOYMENT_RESULT: ${{ needs.deploy_to_production.result }} + PRODUCTION_DEPLOYMENT_SKIPPED: ${{ needs.production_preflight.outputs.should_deploy != 'true' }} + PRODUCTION_ENVIRONMENT_NAME: ${{ env.PRODUCTION_ENVIRONMENT_NAME }} + PRODUCTION_PREFLIGHT_RESULT: ${{ needs.production_preflight.outputs.production_preflight_result }} + PRODUCTION_PROMOTION_REQUESTED: ${{ inputs.promote_to_production }} + PRODUCTION_SKIPPED_REASON: ${{ needs.production_preflight.outputs.production_skipped_reason }} + PRODUCTION_TAG_CREATION_RESULT: ${{ needs.create_production_tag.result }} + PRODUCTION_TAG_NAME: ${{ needs.production_preflight.outputs.production_tag_name }} RELEASE_BRANCH: ${{ needs.build_artifact.outputs.release_branch }} RELEASE_COMMIT_SHA: ${{ needs.build_artifact.outputs.release_commit_sha }} RELEASE_IDENTIFIER: ${{ needs.build_artifact.outputs.release_identifier }} @@ -282,12 +507,39 @@ jobs: fi echo "- Testiny run name: ${TESTINY_RUN_NAME}" + + echo + echo '## Release Cloud Production' + echo "- Production promotion requested: ${PRODUCTION_PROMOTION_REQUESTED}" + echo "- Production preflight result: ${PRODUCTION_PREFLIGHT_RESULT}" + echo "- Production tag: ${PRODUCTION_TAG_NAME}" + echo "- Production deployment skipped: ${PRODUCTION_DEPLOYMENT_SKIPPED}" + + if [[ -n "${PRODUCTION_SKIPPED_REASON}" ]]; then + echo "- Production skip reason: ${PRODUCTION_SKIPPED_REASON}" + fi + + echo "- Production deployment result: ${PRODUCTION_DEPLOYMENT_RESULT}" + echo "- Production tag creation result: ${PRODUCTION_TAG_CREATION_RESULT}" + echo "- Production environment: ${PRODUCTION_ENVIRONMENT_NAME}" + echo "- Production artifact name: ${PRODUCTION_ARTIFACT_NAME}" + echo "- Production artifact checksum: ${PRODUCTION_ARTIFACT_CHECKSUM}" + echo "- Production approval gate: ${PRODUCTION_ENVIRONMENT_NAME} GitHub Environment settings" } >> "$GITHUB_STEP_SUMMARY" notify_release_failure: name: Notify release failure runs-on: ubuntu-24.04 - needs: [build_artifact, deploy_to_beta, create_beta_tag, run_e2e] + needs: + [ + build_artifact, + deploy_to_beta, + create_beta_tag, + run_e2e, + production_preflight, + deploy_to_production, + create_production_tag, + ] if: ${{ always() && contains(join(needs.*.result, ','), 'failure') }} permissions: {} @@ -299,6 +551,6 @@ jobs: with: status: failure text: | - Release Cloud Beta failed for ${{ needs.build_artifact.outputs.release_branch || inputs.release_branch }} (${{ needs.build_artifact.outputs.release_identifier || needs.build_artifact.outputs.release_branch || inputs.release_branch }}) + Release Cloud failed for ${{ needs.build_artifact.outputs.release_branch || inputs.release_branch }} (${{ needs.build_artifact.outputs.release_identifier || needs.build_artifact.outputs.release_branch || inputs.release_branch }}) Triggered by: ${{ github.actor }} Build log: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/bin/releaseMetadataCli.test.ts b/bin/releaseMetadataCli.test.ts index ce9fb9a55b4..c6f6a40e074 100644 --- a/bin/releaseMetadataCli.test.ts +++ b/bin/releaseMetadataCli.test.ts @@ -78,6 +78,26 @@ describe('releaseMetadataCli', () => { }); }); + it('prints the production tag name for the release identifier', () => { + const actualResult = runCommand(['production-tag', '2026-06-19.1']); + + expect(actualResult).toEqual({ + errors: [], + exitCode: 0, + outputs: ['2026-06-19.1-production'], + }); + }); + + it('rejects an invalid production tag release identifier', () => { + const actualResult = runCommand(['production-tag', '2026-06-19']); + + expect(actualResult).toEqual({ + errors: ['Invalid release identifier: 2026-06-19'], + exitCode: 1, + outputs: [], + }); + }); + it('prints usage text for missing command arguments', () => { const actualResult = runCommand(['next-beta-tag']); diff --git a/bin/releaseMetadataCli.ts b/bin/releaseMetadataCli.ts index b09fa8336ca..d7d9bfc09f9 100644 --- a/bin/releaseMetadataCli.ts +++ b/bin/releaseMetadataCli.ts @@ -19,21 +19,31 @@ import process from 'node:process'; -import {createNextBetaTagName, extractReleaseIdentifierFromBranchName} from './releaseMetadata'; +import { + createNextBetaTagName, + createProductionTagName, + extractReleaseIdentifierFromBranchName, +} from './releaseMetadata'; type ReleaseMetadataCliDependencies = { readonly writeError: (message: string) => void; readonly writeOutput: (message: string) => void; }; +const nodeExecutableAndScriptPathArgumentCount = 2; + const usageText = [ 'Usage:', ' releaseMetadataCli.ts release-identifier-from-branch ', ' releaseMetadataCli.ts next-beta-tag [existing-tag ...]', + ' releaseMetadataCli.ts production-tag ', ].join('\n'); function writeResult( - result: ReturnType | ReturnType, + result: + | ReturnType + | ReturnType + | ReturnType, dependencies: ReleaseMetadataCliDependencies, ): number { if (result.isErr) { @@ -59,12 +69,16 @@ export function runReleaseMetadataCli( return writeResult(createNextBetaTagName(primaryValue, remainingValues), dependencies); } + if (commandName === 'production-tag' && primaryValue !== undefined) { + return writeResult(createProductionTagName(primaryValue), dependencies); + } + dependencies.writeError(usageText); return 1; } if (require.main === module) { - process.exitCode = runReleaseMetadataCli(process.argv.slice(2), { + process.exitCode = runReleaseMetadataCli(process.argv.slice(nodeExecutableAndScriptPathArgumentCount), { writeError(message): void { console.error(message); },