diff --git a/.github/workflows/base-deploy.yml b/.github/workflows/base-deploy.yml index 6aae2682..a9127cdb 100644 --- a/.github/workflows/base-deploy.yml +++ b/.github/workflows/base-deploy.yml @@ -89,10 +89,19 @@ jobs: with: terraform_version: ${{ needs.metadata.outputs.terraform_version }} + - name: "Install Poetry" + run: | + curl -sSL https://install.python-poetry.org | python3 - + echo "$HOME/.local/bin" >> $GITHUB_PATH + - name: "Set up Python" uses: actions/setup-python@v5 with: python-version: "3.13" + cache: 'poetry' + + - name: "Install dependencies" + run: poetry install - name: "Checkout repository at ref" uses: actions/checkout@v5 @@ -144,12 +153,6 @@ jobs: echo "Running: make terraform env=$ENVIRONMENT workspace=$WORKSPACE stack=api-layer tf-command=apply" make terraform env=$ENVIRONMENT stack=api-layer tf-command=apply workspace=$WORKSPACE - - name: "Set up git identity" - if: ${{ needs.metadata.outputs.environment == 'preprod' || needs.metadata.outputs.environment == 'prod' }} - run: | - git config user.name "github-actions" - git config user.email "github-actions@github.com" - - name: "Validate Feature Toggles" env: ENV: ${{ needs.metadata.outputs.environment }} @@ -157,122 +160,16 @@ jobs: pip install boto3 python scripts/feature_toggle/validate_toggles.py - # ---------- Preprod path: create RC tag + pre-release ---------- - - name: "Create/Push RC tag for preprod" - if: ${{ needs.metadata.outputs.environment == 'preprod' }} - id: rc_tag - shell: bash - run: | - set -euo pipefail - git fetch --tags - - # Helper: get latest final and latest RC (across all bases) - latest_final="$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' \ - | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1 || true)" - latest_any_rc="$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*-rc.*' \ - | sort -V | tail -n1 || true)" - - # Determine the base version (vX.Y.Z) we will use for the next RC. - # If release_type=rc and we already have RCs, keep the SAME base as the latest RC. - # Otherwise, derive base from latest FINAL and bump per release_type. - if [[ "${{ inputs.release_type }}" == "rc" && -n "${latest_any_rc}" ]]; then - base="${latest_any_rc%-rc.*}" # strip '-rc.N' β†’ vX.Y.Z - else - # Start from latest FINAL (or 0.0.0 if none) - if [[ -z "${latest_final}" ]]; then - base_major=0; base_minor=0; base_patch=0 - else - IFS='.' read -r base_major base_minor base_patch <<< "${latest_final#v}" - fi - - case "${{ inputs.release_type }}" in - major) base_major=$((base_major+1)); base_minor=0; base_patch=0 ;; - minor) base_minor=$((base_minor+1)); base_patch=0 ;; - patch|rc|*) base_patch=$((base_patch+1)) ;; # 'rc' with no prior RCs β†’ default to patch bump - esac - - base="v${base_major}.${base_minor}.${base_patch}" - fi - - # Compute next RC number for this base - last_rc_for_base="$(git tag -l "${base}-rc.*" | sort -V | tail -n1 || true)" - if [[ -z "${last_rc_for_base}" ]]; then - next_rc="${base}-rc.1" - else - n="${last_rc_for_base##*-rc.}" - next_rc="${base}-rc.$((n+1))" - fi - - # Tag current commit (whatever ref was checked out) - sha="$(git rev-parse HEAD)" - echo "Tagging ${sha} as ${next_rc}" - git tag -a "${next_rc}" "${sha}" -m "Release candidate ${next_rc}" - git push origin "${next_rc}" - - echo "rc=${next_rc}" >> "$GITHUB_OUTPUT" - - - name: "Create GitHub Pre-release (preprod)" - if: ${{ needs.metadata.outputs.environment == 'preprod' }} - uses: actions/create-release@v1 + - name: "Tag and Release" + if: ${{ needs.metadata.outputs.environment == 'preprod' || needs.metadata.outputs.environment == 'prod' }} env: + ENVIRONMENT: ${{ needs.metadata.outputs.environment }} + REF: ${{ needs.metadata.outputs.ref }} + INPUT_RELEASE_TYPE: ${{ inputs.release_type }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ steps.rc_tag.outputs.rc }} - release_name: "Pre-release ${{ steps.rc_tag.outputs.rc }}" - body: | - Auto pre-release created during preprod deployment. - draft: false - prerelease: true - - # ---------- Prod path: promote RC to final ---------- - - name: "Validate input is an RC tag (prod)" - if: ${{ needs.metadata.outputs.environment == 'prod' }} - shell: bash - run: | - set -euo pipefail - ref="${{ needs.metadata.outputs.ref }}" - if [[ ! "$ref" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then - echo "ERROR: For prod, 'ref' must be an RC tag like v1.4.0-rc.2 (got: $ref)" - exit 1 - fi - git fetch --tags --quiet - if ! git rev-parse -q --verify "refs/tags/$ref" >/dev/null; then - echo "ERROR: Tag '$ref' does not exist on origin." - exit 1 - fi - - - name: "Create final tag from RC (prod)" - if: ${{ needs.metadata.outputs.environment == 'prod' }} - id: final_tag - shell: bash - run: | - set -euo pipefail - rc="${{ needs.metadata.outputs.ref }}" - final="${rc%-rc.*}" # strip '-rc.N' - sha=$(git rev-list -n 1 "$rc") - - if git rev-parse -q --verify "refs/tags/${final}" >/dev/null; then - echo "ERROR: Final tag ${final} already exists." - exit 1 - fi + GITHUB_REPOSITORY: ${{ github.repository }} + run: poetry run python scripts/workflow/tag_and_release.py - echo "Promoting $rc ($sha) to final $final" - git tag -a "${final}" "${sha}" -m "Release ${final}" - git push origin "${final}" - echo "final=${final}" >> $GITHUB_OUTPUT - - - name: "Create GitHub Release (prod)" - if: ${{ needs.metadata.outputs.environment == 'prod' }} - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ steps.final_tag.outputs.final }} - release_name: "Release ${{ steps.final_tag.outputs.final }}" - body: | - Auto-release created during production deployment. - draft: false - prerelease: false regression-tests: name: "Regression Tests" diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml index f40e247f..ab77dccd 100644 --- a/.github/workflows/cicd-1-pull-request.yaml +++ b/.github/workflows/cicd-1-pull-request.yaml @@ -1,4 +1,4 @@ -name: "CI/CD pull request" +name: "1. CI | Pull Request" # The total recommended execution time for the "CI/CD Pull Request" workflow is around 20 minutes. diff --git a/.github/workflows/cicd-2-publish.yaml b/.github/workflows/cicd-2-publish.yaml index 2f67fb5e..fc77fc10 100644 --- a/.github/workflows/cicd-2-publish.yaml +++ b/.github/workflows/cicd-2-publish.yaml @@ -2,7 +2,7 @@ # Triggered on push to main. Tags the commit with a dev- label. # Does not create GitHub Releases or production tags (v1.x.x). -name: "CI/CD publish" +name: "2. CD | Deploy to Dev" on: push: diff --git a/.github/workflows/cicd-3-test_auto.yaml b/.github/workflows/cicd-3-test-deploy.yaml similarity index 95% rename from .github/workflows/cicd-3-test_auto.yaml rename to .github/workflows/cicd-3-test-deploy.yaml index 49423ca0..723105be 100644 --- a/.github/workflows/cicd-3-test_auto.yaml +++ b/.github/workflows/cicd-3-test-deploy.yaml @@ -1,14 +1,10 @@ -name: "Auto Deploy to test" +name: "3. CD | Deploy to Test" on: workflow_run: workflows: ["CI/CD publish"] types: [completed] -concurrency: - group: terraform-deploy-test - cancel-in-progress: false - permissions: contents: read id-token: write @@ -55,10 +51,16 @@ jobs: runs-on: ubuntu-latest needs: [metadata] environment: test + timeout-minutes: 10080 permissions: id-token: write contents: read steps: + - name: "Acquire deploy lock" + uses: softprops/turnstyle@v2 + with: + poll-interval-seconds: 10 + - name: "Checkout same commit" uses: actions/checkout@v5 with: diff --git a/.github/workflows/cicd-3-test.yaml b/.github/workflows/cicd-3-test.yaml deleted file mode 100644 index 23cf55b8..00000000 --- a/.github/workflows/cicd-3-test.yaml +++ /dev/null @@ -1,141 +0,0 @@ -# Deploys a given tag to test environment -# Does not tag or create a release - -name: "CI/CD deploy to TEST" - -concurrency: - group: terraform-deploy-${{ github.event.inputs.environment }} - cancel-in-progress: false - -on: - workflow_dispatch: - inputs: - tag: - description: "This is the tag that is going to be deployed" - required: true - default: "latest" - environment: - description: "Target environment (test only)" - required: true - default: "test" - type: choice - options: - - test - -jobs: - metadata: - name: "Set CI/CD metadata" - runs-on: ubuntu-latest - timeout-minutes: 1 - outputs: - build_datetime: ${{ steps.variables.outputs.build_datetime }} - build_timestamp: ${{ steps.variables.outputs.build_timestamp }} - build_epoch: ${{ steps.variables.outputs.build_epoch }} - nodejs_version: ${{ steps.variables.outputs.nodejs_version }} - python_version: ${{ steps.variables.outputs.python_version }} - terraform_version: ${{ steps.variables.outputs.terraform_version }} - version: ${{ steps.variables.outputs.version }} - tag: ${{ steps.variables.outputs.tag }} - steps: - - name: "Checkout tag" - uses: actions/checkout@v5 - with: - ref: ${{ github.event.inputs.tag }} - - - name: "Set CI/CD variables" - id: variables - run: | - datetime=$(date -u +'%Y-%m-%dT%H:%M:%S%z') - echo "build_datetime=$datetime" >> $GITHUB_OUTPUT - echo "build_timestamp=$(date --date=$datetime -u +'%Y%m%d%H%M%S')" >> $GITHUB_OUTPUT - echo "build_epoch=$(date --date=$datetime -u +'%s')" >> $GITHUB_OUTPUT - echo "nodejs_version=$(grep "^nodejs" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT - echo "python_version=$(grep "^nodejs" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT - echo "terraform_version=$(grep "^terraform" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT - # TODO: Get the version, but it may not be the .version file as this should come from the CI/CD Pull Request Workflow - echo "version=$(head -n 1 .version 2> /dev/null || echo unknown)" >> $GITHUB_OUTPUT - echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT - - name: "List variables" - run: | - export BUILD_DATETIME="${{ steps.variables.outputs.build_datetime }}" - export BUILD_TIMESTAMP="${{ steps.variables.outputs.build_timestamp }}" - export BUILD_EPOCH="${{ steps.variables.outputs.build_epoch }}" - export NODEJS_VERSION="${{ steps.variables.outputs.nodejs_version }}" - export PYTHON_VERSION="${{ steps.variables.outputs.python_version }}" - export TERRAFORM_VERSION="${{ steps.variables.outputs.terraform_version }}" - export VERSION="${{ steps.variables.outputs.version }}" - export TAG="${{ steps.variables.outputs.tag }}" - make list-variables - deploy: - name: "Deploy to an environment" - runs-on: ubuntu-latest - needs: [metadata] - environment: ${{ inputs.environment }} - timeout-minutes: 30 - permissions: - id-token: write - contents: write - steps: - - name: "Setup Terraform" - uses: hashicorp/setup-terraform@v3 - with: - terraform_version: ${{ needs.metadata.outputs.terraform_version }} - - - name: "Set up Python" - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - - name: "Checkout Repository" - uses: actions/checkout@v5 - with: - ref: ${{ github.event.inputs.tag }} - - - name: "Build lambda artefact" - run: | - make dependencies install-python - make build - - - name: "Upload lambda artefact" - uses: actions/upload-artifact@v4 - with: - name: lambda - path: dist/lambda.zip - - - name: "Download Built Lambdas" - uses: actions/download-artifact@v5 - with: - name: lambda - path: ./build - - - name: "Configure AWS Credentials" - uses: aws-actions/configure-aws-credentials@v5 - with: - role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/service-roles/github-actions-api-deployment-role - aws-region: eu-west-2 - - - name: "Terraform Apply" - env: - ENVIRONMENT: ${{ inputs.environment }} - WORKSPACE: "default" - TF_VAR_API_CA_CERT: ${{ secrets.API_CA_CERT }} - TF_VAR_API_CLIENT_CERT: ${{ secrets.API_CLIENT_CERT }} - TF_VAR_API_PRIVATE_KEY_CERT: ${{ secrets.API_PRIVATE_KEY_CERT }} - TF_VAR_SPLUNK_HEC_TOKEN: ${{ secrets.SPLUNK_HEC_TOKEN }} - TF_VAR_SPLUNK_HEC_ENDPOINT: ${{ secrets.SPLUNK_HEC_ENDPOINT }} - run: | - mkdir -p ./build - echo "Running: make terraform env=$ENVIRONMENT workspace=$WORKSPACE stack=networking tf-command=apply" - make terraform env=$ENVIRONMENT stack=networking tf-command=apply workspace=$WORKSPACE - echo "Running: make terraform env=$ENVIRONMENT workspace=$WORKSPACE stack=api-layer tf-command=apply" - make terraform env=$ENVIRONMENT stack=api-layer tf-command=apply workspace=$WORKSPACE - working-directory: ./infrastructure - - regression-tests: - name: "Regression Tests" - needs: deploy - uses: ./.github/workflows/regression-tests.yml - with: - ENVIRONMENT: "test" - VERSION_NUMBER: "main" - secrets: inherit diff --git a/.github/workflows/cicd-4a-preprod-deploy.yml b/.github/workflows/cicd-4-preprod-deploy.yml similarity index 95% rename from .github/workflows/cicd-4a-preprod-deploy.yml rename to .github/workflows/cicd-4-preprod-deploy.yml index 98371584..c2acb08c 100644 --- a/.github/workflows/cicd-4a-preprod-deploy.yml +++ b/.github/workflows/cicd-4-preprod-deploy.yml @@ -1,4 +1,4 @@ -name: Preprod Deploy +name: "4. CD | Deploy to PreProd" concurrency: group: terraform-deploy-preprod diff --git a/.github/workflows/cicd-4b-preprod-seed-users.yml b/.github/workflows/cicd-4b-preprod-seed-users.yml deleted file mode 100644 index e9b911b1..00000000 --- a/.github/workflows/cicd-4b-preprod-seed-users.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: preprod - Seed DynamoDB table - -concurrency: - group: seed-preprod-dynamodb - cancel-in-progress: false - -on: - workflow_run: - workflows: [ "Preprod Deploy" ] - types: - - completed - filters: - conclusion: - - success - workflow_dispatch: - inputs: - environment: - description: Target environment - required: true - type: choice - options: - - preprod - -jobs: - seed-dynamodb: - runs-on: ubuntu-latest - environment: "preprod" - permissions: - id-token: write - contents: read - env: - AWS_REGION: eu-west-2 - DATA_FOLDER: tests/e2e/data/dynamoDB/vitaIntegrationTestData - DYNAMODB_TABLE: eligibility-signposting-api-preprod-eligibility_datastore - - steps: - - name: Checkout repo - uses: actions/checkout@v5 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.13' - - - name: Install dependencies - run: pip install boto3 - - - name: "Configure AWS Credentials" - uses: aws-actions/configure-aws-credentials@v5 - with: - role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/service-roles/github-actions-api-deployment-role - aws-region: ${{ env.AWS_REGION }} - - - name: Run seed script - run: | - python scripts/seed_users/seed_dynamodb.py \ - --table-name "${{ env.DYNAMODB_TABLE }}" \ - --region "${{ env.AWS_REGION }}" \ - --data-folder "${{ env.DATA_FOLDER }}" diff --git a/.github/workflows/cicd-5-prod-deploy.yml b/.github/workflows/cicd-5-prod-deploy.yml index 90edf55e..f5f30f37 100644 --- a/.github/workflows/cicd-5-prod-deploy.yml +++ b/.github/workflows/cicd-5-prod-deploy.yml @@ -1,4 +1,4 @@ -name: Prod Promote +name: "5. CD | Deploy to Prod" concurrency: group: terraform-deploy-prod diff --git a/scripts/seed_users/README.md b/scripts/seed_users/README.md deleted file mode 100644 index 3116ce9e..00000000 --- a/scripts/seed_users/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# 🧬 DynamoDB Seeder Script - -This script deletes and inserts items into a DynamoDB table using JSON seed data. It’s designed for integration testing and local development workflows. -This script is user in the Preprod seed workflow. - ---- - -## πŸ“¦ Requirements - -- Python 3.13 -- AWS credentials configured (via `~/.aws/credentials`, environment variables, or IAM role) -- Required Python packages: - - ```bash - pip install boto3 - ``` - ---- - -## πŸš€ Usage - -From the project root, run: - -```bash -python scripts/seed_users/seed_dynamodb.py \ - --table-name \ - --region \ - --data-folder -``` - -### Example - -```bash -python scripts/seed_users/seed_dynamodb.py \ - --table-name eligibility-signposting-api-dev-eligibility_datastore \ - --region eu-west-2 \ - --data-folder tests/e2e/data/dynamoDB/vitaIntegrationTestData -``` - ---- - -## πŸ“ JSON Data Format - -Each `.json` file in the specified folder should follow this structure: - -```json -{ - "data": [ - { - "NHS_NUMBER": "1234567890", - "ATTRIBUTE_TYPE": "COHORTS", - "otherAttribute1": "value", - "otherAttribute2": "value" - } - ] -} -``` - -## 🧹 What It Does - -1. **Deletes** existing items in the table matching `NHS_NUMBER` from all JSON files. -2. **Inserts** all items from the same files into the table. - ---- - -## πŸ›‘οΈ Safety Notes - -- This script performs destructive operations β€” do not use this in prod environment. -- Ensure your AWS credentials have appropriate permissions for `dynamodb:DeleteItem` and `dynamodb:PutItem`. - ---- diff --git a/scripts/seed_users/seed_dynamodb.py b/scripts/seed_users/seed_dynamodb.py deleted file mode 100644 index d5e4dea6..00000000 --- a/scripts/seed_users/seed_dynamodb.py +++ /dev/null @@ -1,82 +0,0 @@ -import argparse -import glob -import json -import os - -import boto3 - - -def parse_args(): - parser = argparse.ArgumentParser(description="Seed DynamoDB table with JSON data.") - parser.add_argument("--table-name", required=True, help="Name of the DynamoDB table") - parser.add_argument("--region", default="eu-west-2", help="AWS region") - parser.add_argument("--data-folder", default="vitaIntegrationTestData/", help="Folder containing JSON seed data") - return parser.parse_args() - - -def resolve_data_folder(path): - return os.path.abspath(path) - - -def get_unique_nhs_numbers(data_folder): - nhs_numbers = set() - json_files = glob.glob(os.path.join(data_folder, "*.json")) - for file_path in json_files: - with open(file_path) as f: - payload = json.load(f) - items = payload.get("data", []) - for item in items: - nhs_number = item.get("NHS_NUMBER") - if nhs_number: - nhs_numbers.add(nhs_number) - return list(nhs_numbers) - - -def delete_all_items_for_nhs_numbers(table, nhs_numbers): - for nhs_number in nhs_numbers: - response = table.query( - KeyConditionExpression=boto3.dynamodb.conditions.Key("NHS_NUMBER").eq(nhs_number) - ) - items = response.get("Items", []) - with table.batch_writer() as batch: - for item in items: - key = { - "NHS_NUMBER": item["NHS_NUMBER"], - "ATTRIBUTE_TYPE": item["ATTRIBUTE_TYPE"] - } - batch.delete_item(Key=key) - - -def insert_data_from_folder(table, data_folder): - json_files = glob.glob(os.path.join(data_folder, "*.json")) - for file_path in json_files: - with open(file_path) as f: - payload = json.load(f) - items = payload.get("data", []) - - with table.batch_writer() as batch: - for item in items: - nhs_number = item.get("NHS_NUMBER") - attr_type = item.get("ATTRIBUTE_TYPE") - if nhs_number and attr_type: - item["id"] = nhs_number - batch.put_item(Item=item) - - -def main(): - args = parse_args() - - dynamodb = boto3.resource("dynamodb", region_name=args.region) - table = dynamodb.Table(args.table_name) - - data_folder = resolve_data_folder(args.data_folder) - if not os.path.isdir(data_folder): - raise ValueError(f"Data folder '{data_folder}' does not exist or is not a directory.") - - nhs_numbers = get_unique_nhs_numbers(data_folder) - delete_all_items_for_nhs_numbers(table, nhs_numbers) - insert_data_from_folder(table, data_folder) - - -if __name__ == "__main__": - main() diff --git a/scripts/seed_users/__init__.py b/scripts/workflow/__init__.py similarity index 100% rename from scripts/seed_users/__init__.py rename to scripts/workflow/__init__.py diff --git a/scripts/workflow/tag_and_release.py b/scripts/workflow/tag_and_release.py new file mode 100644 index 00000000..ff61de02 --- /dev/null +++ b/scripts/workflow/tag_and_release.py @@ -0,0 +1,155 @@ +import os +import re +import subprocess +import sys +import requests + +def run_command(command, check=True): + """Runs a shell command and returns its stripped standard output.""" + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + shell=True, + check=check + ) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + print(f"Error running command '{command}': {e.stderr.strip()}", file=sys.stderr) + sys.exit(1) + +def set_github_output(name, value): + """Writes an output to the GITHUB_OUTPUT file.""" + github_output_file = os.environ.get("GITHUB_OUTPUT") + if github_output_file: + try: + with open(github_output_file, "a") as f: + f.write(f"{name}={value}\n") + except IOError as e: + print(f"Error writing to GITHUB_OUTPUT file: {e}", file=sys.stderr) + sys.exit(1) + +def create_github_release(tag_name, prerelease, token): + """Creates a GitHub release using the GitHub API.""" + repo = os.environ.get("GITHUB_REPOSITORY") + if not repo: + print("Error: GITHUB_REPOSITORY environment variable not set.", file=sys.stderr) + sys.exit(1) + + url = f"https://api.github.com/repos/{repo}/releases" + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + } + + release_name = f"Pre-release {tag_name}" if prerelease else f"Release {tag_name}" + body = "Auto pre-release created during preprod deployment." if prerelease else "Auto-release created during production deployment." + + data = { + "tag_name": tag_name, + "name": release_name, + "body": body, + "draft": False, + "prerelease": prerelease, + } + + response = requests.post(url, headers=headers, json=data) + + if response.status_code == 201: + print(f"Successfully created GitHub release for tag {tag_name}.") + else: + print(f"Error creating GitHub release: {response.status_code} {response.text}", file=sys.stderr) + sys.exit(1) + +def handle_preprod(): + """Handles the pre-production release logic.""" + run_command("git fetch --tags") + + latest_final_cmd = "git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -E '^v[0-9]+\\.[0-9]+\\.[0-9]+$' | sort -V | tail -n1" + latest_final = run_command(latest_final_cmd, check=False) + + latest_any_rc_cmd = "git tag -l 'v[0-9]*.[0-9]*.[0-9]*-rc.*' | sort -V | tail -n1" + latest_any_rc = run_command(latest_any_rc_cmd, check=False) + + release_type = os.environ.get("INPUT_RELEASE_TYPE", "patch") + + if release_type == "rc" and latest_any_rc: + base = latest_any_rc.split('-rc.')[0] + else: + if not latest_final: + base_major, base_minor, base_patch = 0, 0, 0 + else: + version_part = latest_final.lstrip('v') + base_major, base_minor, base_patch = map(int, version_part.split('.')) + + if release_type == "major": + base_major += 1 + base_minor, base_patch = 0, 0 + elif release_type == "minor": + base_minor += 1 + base_patch = 0 + else: + base_patch += 1 + + base = f"v{base_major}.{base_minor}.{base_patch}" + + last_rc_for_base_cmd = f"git tag -l '{base}-rc.*' | sort -V | tail -n1" + last_rc_for_base = run_command(last_rc_for_base_cmd, check=False) + + if not last_rc_for_base: + next_rc = f"{base}-rc.1" + else: + n = int(last_rc_for_base.split('-rc.')[-1]) + next_rc = f"{base}-rc.{n + 1}" + + sha = run_command("git rev-parse HEAD") + print(f"Tagging {sha} as {next_rc}") + run_command(f"git tag -a '{next_rc}' '{sha}' -m 'Release candidate {next_rc}'") + run_command(f"git push origin '{next_rc}'") + + set_github_output("rc", next_rc) + create_github_release(next_rc, prerelease=True, token=os.environ["GITHUB_TOKEN"]) + +def handle_prod(): + """Handles the production release logic.""" + ref = os.environ["REF"] + + if not re.match(r"^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$", ref): + print(f"ERROR: For prod, 'ref' must be an RC tag like v1.4.0-rc.2 (got: {ref})", file=sys.stderr) + sys.exit(1) + + run_command("git fetch --tags --quiet") + + if run_command(f"git rev-parse -q --verify 'refs/tags/{ref}'", check=False) == "": + print(f"ERROR: Tag '{ref}' does not exist on origin.", file=sys.stderr) + sys.exit(1) + + final_tag = ref.split("-rc.")[0] + sha = run_command(f"git rev-list -n 1 '{ref}'") + + if run_command(f"git rev-parse -q --verify 'refs/tags/{final_tag}'", check=False) != "": + print(f"ERROR: Final tag {final_tag} already exists.", file=sys.stderr) + sys.exit(1) + + print(f"Promoting {ref} ({sha}) to final {final_tag}") + run_command(f"git tag -a '{final_tag}' '{sha}' -m 'Release {final_tag}'") + run_command(f"git push origin '{final_tag}'") + + set_github_output("final", final_tag) + create_github_release(final_tag, prerelease=False, token=os.environ["GITHUB_TOKEN"]) + +def main(): + """Main function to run release management based on environment.""" + run_command('git config user.name "github-actions" && git config user.email "github-actions@github.com"') + + environment = os.environ.get("ENVIRONMENT") + if environment == "preprod": + handle_preprod() + elif environment == "prod": + handle_prod() + else: + print(f"Warning: No release management action for environment '{environment}'. Skipping.", file=sys.stderr) + +if __name__ == "__main__": + main()