diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml index 73476cb913f4..6737a1996806 100644 --- a/.github/workflows/build_wheels.yml +++ b/.github/workflows/build_wheels.yml @@ -52,13 +52,12 @@ jobs: run: python -m pip install wheel - name: Build source working-directory: ./sdks/python - run: python setup.py sdist --formats=gztar,zip + run: python setup.py sdist --formats=zip - name: Add checksums working-directory: ./sdks/python/dist run: | - for file in *.zip *.tar.gz; do - sha512sum $file > ${file}.sha512 - done + file=$(ls | grep .zip | head -n 1) + sha512sum $file > ${file}.sha512 - name: Unzip source working-directory: ./sdks/python run: unzip dist/$(ls dist | grep .zip | head -n 1) @@ -73,7 +72,7 @@ jobs: - name: Upload compressed sources as artifacts uses: actions/upload-artifact@v2 with: - name: source_gztar_zip + name: source_zip path: sdks/python/dist prepare_gcs: @@ -99,7 +98,7 @@ jobs: - name: Download compressed sources from artifacts uses: actions/download-artifact@v2 with: - name: source_gztar_zip + name: source_zip path: source/ - name: Authenticate on GCP uses: GoogleCloudPlatform/github-actions/setup-gcloud@master diff --git a/release/src/main/python-release/python_release_automation.sh b/release/src/main/python-release/python_release_automation.sh index 4c527621608e..73f87a1530a6 100755 --- a/release/src/main/python-release/python_release_automation.sh +++ b/release/src/main/python-release/python_release_automation.sh @@ -19,7 +19,7 @@ source release/src/main/python-release/run_release_candidate_python_quickstart.sh source release/src/main/python-release/run_release_candidate_python_mobile_gaming.sh -for version in 2.7 3.5 3.6 3.7 +for version in 2.7 3.5 3.6 3.7 3.8 do run_release_candidate_python_quickstart "tar" "python${version}" run_release_candidate_python_mobile_gaming "tar" "python${version}" diff --git a/release/src/main/scripts/build_release_candidate.sh b/release/src/main/scripts/build_release_candidate.sh index fdf292c8b358..90eed1afeb3f 100755 --- a/release/src/main/scripts/build_release_candidate.sh +++ b/release/src/main/scripts/build_release_candidate.sh @@ -19,7 +19,7 @@ # This script will create a Release Candidate, includes: # 1. Build and stage java artifacts # 2. Stage source release on dist.apache.org -# 3. Stage python binaries +# 3. Stage python source distribution and wheels on dist.apache.org # 4. Stage SDK docker images # 5. Create a PR to update beam-site @@ -36,7 +36,8 @@ LOCAL_WEBSITE_REPO=beam_website_repo USER_REMOTE_URL= USER_GITHUB_ID= -GIT_REPO_URL=git@github.com:apache/beam.git +GIT_REPO_BASE_URL=apache/beam +GIT_REPO_URL=git@github.com:${GIT_REPO_BASE_URL}.git ROOT_SVN_URL=https://dist.apache.org/repos/dist/dev/beam GIT_BEAM_ARCHIVE=https://github.com/apache/beam/archive GIT_BEAM_WEBSITE=https://github.com/apache/beam-site.git @@ -62,6 +63,8 @@ read USER_GITHUB_ID USER_REMOTE_URL=git@github.com:${USER_GITHUB_ID}/beam-site +echo "=================Pre-requirements====================" +echo "Please make sure you have configured and started your gpg by running ./preparation_before_release.sh." echo "================Listing all GPG keys=================" gpg --list-keys --keyid-format LONG --fingerprint --fingerprint echo "Please copy the public key which is associated with your Apache account:" @@ -154,41 +157,58 @@ if [[ $confirmation = "y" ]]; then rm -rf ~/${LOCAL_JAVA_STAGING_DIR} fi -echo "[Current Step]: Stage python binaries" + +echo "[Current Step]: Stage python source distribution and wheels on dist.apache.org" echo "Do you want to proceed? [y|N]" read confirmation if [[ $confirmation = "y" ]]; then echo "============Staging Python Binaries on dist.apache.org=========" cd ~ - if [[ -d ${LOCAL_PYTHON_STAGING_DIR} ]]; then - rm -rf ${LOCAL_PYTHON_STAGING_DIR} + if [[ -d "${LOCAL_PYTHON_STAGING_DIR}" ]]; then + rm -rf "${LOCAL_PYTHON_STAGING_DIR}" fi - mkdir -p ${LOCAL_PYTHON_STAGING_DIR} - cd ${LOCAL_PYTHON_STAGING_DIR} + mkdir -p "${LOCAL_PYTHON_STAGING_DIR}" + cd "${LOCAL_PYTHON_STAGING_DIR}" echo '-------------------Cloning Beam Release Branch-----------------' - git clone ${GIT_REPO_URL} - cd ${BEAM_ROOT_DIR} - git checkout ${RELEASE_BRANCH} + git clone "${GIT_REPO_URL}" + cd "${BEAM_ROOT_DIR}" + git checkout "${RELEASE_BRANCH}" + RELEASE_COMMIT=$(git rev-parse --verify HEAD) + + echo '-------------------Creating Python Virtualenv-----------------' + python3 -m venv "${LOCAL_PYTHON_VIRTUALENV}" + source "${LOCAL_PYTHON_VIRTUALENV}/bin/activate" + pip install requests python-dateutil + + echo '--------------Fetching GitHub Actions Artifacts--------------' + SVN_ARTIFACTS_DIR="beam/${RELEASE}/${PYTHON_ARTIFACTS_DIR}" + svn co https://dist.apache.org/repos/dist/dev/beam + mkdir -p "${SVN_ARTIFACTS_DIR}" + python release/src/main/scripts/download_github_actions_artifacts.py \ + --github-user "${USER_GITHUB_ID}" \ + --repo-url "${GIT_REPO_BASE_URL}" \ + --release-branch "${RELEASE_BRANCH}" \ + --release-commit "${RELEASE_COMMIT}" \ + --artifacts_dir "${SVN_ARTIFACTS_DIR}" - echo '-------------------Generating Python Artifacts-----------------' - cd sdks/python - virtualenv ${LOCAL_PYTHON_VIRTUALENV} - source ${LOCAL_PYTHON_VIRTUALENV}/bin/activate - pip install -r build-requirements.txt - python setup.py sdist --format=zip - cd dist + cd "${SVN_ARTIFACTS_DIR}" - svn co https://dist.apache.org/repos/dist/dev/beam - mkdir -p beam/${RELEASE}/${PYTHON_ARTIFACTS_DIR} - cp apache-beam-${RELEASE}.zip beam/${RELEASE}/${PYTHON_ARTIFACTS_DIR}/apache-beam-${RELEASE}.zip - cd beam/${RELEASE}/${PYTHON_ARTIFACTS_DIR} + echo "------Checking Hash Value for apache-beam-${RELEASE}.zip-----" + sha512sum -c "apache-beam-${RELEASE}.zip.sha512" echo "------Signing Source Release apache-beam-${RELEASE}.zip------" - gpg --local-user ${SIGNING_KEY} --armor --detach-sig apache-beam-${RELEASE}.zip + gpg --local-user "${SIGNING_KEY}" --armor --detach-sig "apache-beam-${RELEASE}.zip" - echo "------Creating Hash Value for apache-beam-${RELEASE}.zip------" - sha512sum apache-beam-${RELEASE}.zip > apache-beam-${RELEASE}.zip.sha512 + for artifact in *.whl; do + echo "----------Checking Hash Value for ${artifact} wheel-----------" + sha512sum -c "${artifact}.sha512" + done + + for artifact in *.whl; do + echo "------------------Signing ${artifact} wheel-------------------" + gpg --local-user "${SIGNING_KEY}" --armor --detach-sig "${artifact}" + done cd .. svn add --force ${PYTHON_ARTIFACTS_DIR} @@ -197,11 +217,11 @@ if [[ $confirmation = "y" ]]; then read confirmation if [[ $confirmation != "y" ]]; then echo "Exit without staging python artifacts on dist.apache.org." - rm -rf ~/${PYTHON_ARTIFACTS_DIR} + rm -rf "${HOME:?}/${LOCAL_PYTHON_STAGING_DIR}" exit fi svn commit --no-auth-cache - rm -rf ~/${PYTHON_ARTIFACTS_DIR} + rm -rf "${HOME:?}/${LOCAL_PYTHON_STAGING_DIR}" fi echo "[Current Step]: Stage docker images" diff --git a/release/src/main/scripts/download_github_actions_artifacts.py b/release/src/main/scripts/download_github_actions_artifacts.py new file mode 100644 index 000000000000..3fdbb282411b --- /dev/null +++ b/release/src/main/scripts/download_github_actions_artifacts.py @@ -0,0 +1,326 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""Script for downloading GitHub Actions artifacts from 'Build python wheels' workflow.""" +import argparse +import itertools +import os +import pprint +import shutil +import sys +import tempfile +import time +import zipfile + +import dateutil.parser +import requests + +GH_API_URL_WORKLOW_FMT = "https://api.github.com/repos/{repo_url}/actions/workflows/build_wheels.yml" +GH_API_URL_WORKFLOW_RUNS_FMT = "https://api.github.com/repos/{repo_url}/actions/workflows/{workflow_id}/runs" +GH_API_URL_WORKFLOW_RUN_FMT = "https://api.github.com/repos/{repo_url}/actions/runs/{run_id}" +GH_WEB_URL_WORKLOW_RUN_FMT = "https://github.com/{repo_url}/actions/runs/{run_id}" + + +def parse_arguments(): + """ + Gets all neccessary data from the user by parsing arguments or asking for input. + Return: github_token, user_github_id, repo_url, release_branch, release_commit, artifacts_dir + """ + parser = argparse.ArgumentParser( + description= + "Script for downloading GitHub Actions artifacts from 'Build python wheels' workflow." + ) + parser.add_argument("--github-user", required=True) + parser.add_argument("--repo-url", required=True) + parser.add_argument("--release-branch", required=True) + parser.add_argument("--release-commit", required=True) + parser.add_argument("--artifacts_dir", required=True) + + args = parser.parse_args() + github_token = ask_for_github_token() + + print("You passed following arguments:") + pprint.pprint({**vars(args), **{"github_token": github_token}}) + + if not get_yes_or_no_answer("Do you want to continue?"): + print("You said NO. Quitting ...") + sys.exit(1) + + user_github_id = args.github_user + repo_url = args.repo_url + release_branch = args.release_branch + release_commit = args.release_commit + artifacts_dir = args.artifacts_dir if os.path.isabs(args.artifacts_dir) \ + else os.path.abspath(args.artifacts_dir) + + return github_token, user_github_id, repo_url, release_branch, release_commit, artifacts_dir + + +def ask_for_github_token(): + """Ask for github token and print basic information about it.""" + url = "https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token" + message = ( + f"You need to have a github access token with public_repo scope. " + f"More info about creating access tokens can be found here {url}") + print(message) + github_token = input("Enter github token: ") + if not github_token: + return ask_for_github_token() + return github_token + + +def request_url(url, github_token, return_json=True, *args, **kwargs): + """Helper function for making requests authorized by GitHub token.""" + r = requests.get(url, *args, auth=("token", github_token), **kwargs) + if return_json: + r.raise_for_status() + return r.json() + return r + + +def safe_get(data, key, url=None): + """Looks up attribute values from a parsed JSON HTTP response.""" + if key not in data: + message = f'There is missing key: "{key}" in response data: {data}.' + if url: + message += f" Requested url: {url}" + raise ValueError(message) + return data.get(key) + + +def get_yes_or_no_answer(question): + """Asks yes or no question.""" + reply = str(input(question + " 'y' or 'n'): ")).lower().strip() + if reply == "y": + return True + elif reply == "n": + return False + else: + return get_yes_or_no_answer("Uhhhh... please enter") + + +def get_build_wheels_workflow_id(repo_url, github_token): + """Gets the ID of the Github Actions workflow responsible for building wheels.""" + url = GH_API_URL_WORKLOW_FMT.format(repo_url=repo_url) + data = request_url(url, github_token) + return safe_get(data, "id", url) + + +def get_single_workflow_run_data(run_id, repo_url, github_token): + """Gets single workflow run data (github api payload).""" + url = GH_API_URL_WORKFLOW_RUN_FMT.format(repo_url=repo_url, run_id=run_id) + return request_url(url, github_token) + + +def get_last_run_id( + workflow_id, repo_url, release_branch, release_commit, github_token): + """ + Gets id of last run for given workflow, repo, branch and commit. + Raises exception when no run found. + """ + url = GH_API_URL_WORKFLOW_RUNS_FMT.format( + repo_url=repo_url, workflow_id=workflow_id) + data = request_url( + url, + github_token, + params={ + "event": "push", "branch": release_branch + }, + ) + runs = safe_get(data, "workflow_runs", url) + + filtered_commit_runs = [ + r for r in runs if r.get("head_sha", "") == release_commit + ] + + if not filtered_commit_runs: + workflow_run_web_url = GH_API_URL_WORKFLOW_RUNS_FMT.format( + repo_url=repo_url, workflow_id=workflow_id) + raise Exception( + f"No runs for workflow (branch {release_branch}, commit {release_commit}). Verify at {workflow_run_web_url}" + ) + + sorted_runs = sorted( + filtered_commit_runs, + key=lambda w: dateutil.parser.parse(w["created_at"]), + reverse=True, + ) + last_run = sorted_runs[0] + last_run_id = safe_get(last_run, "id") + print( + f"Found last run. SHA: {release_commit}, created_at: '{last_run['created_at']}', id: {last_run_id}" + ) + workflow_run_web_url = GH_WEB_URL_WORKLOW_RUN_FMT.format( + repo_url=repo_url, run_id=last_run_id) + print(f"Verify at {workflow_run_web_url}") + print( + f"GCS location corresponding to artifacts built in this run: " + f"gs://beam-wheels-staging/{release_branch}/{release_commit}-{last_run_id}/" + ) + return last_run_id + + +def validate_run(run_id, repo_url, github_token): + """Validates workflow run. Verifies succesfull status and waits if run is not finished.""" + run_data = get_single_workflow_run_data(run_id, repo_url, github_token) + status = safe_get(run_data, "status") + conclusion = safe_get(run_data, "conclusion") + + if status == "completed" and conclusion == "success": + return run_id + elif status in ["queued", "in_progress"]: + wait_for_workflow_run_to_finish( + run_id, repo_url, status, conclusion, github_token) + else: + run_web_url = GH_WEB_URL_WORKLOW_RUN_FMT.format( + repo_url=repo_url, run_id=run_id) + raise Exception( + f"Run unsuccessful. Status: {status}. Conclusion: {conclusion}. Check at: {run_web_url}" + ) + + +def wait_for_workflow_run_to_finish( + run_id, repo_url, status, conclusion, github_token): + """Waits for given workflow run to finish succesfully""" + run_web_url = GH_WEB_URL_WORKLOW_RUN_FMT.format( + repo_url=repo_url, run_id=run_id) + print( + f"Started waiting for Workflow run {run_id} to finish. Check on {run_web_url}" + ) + start_time = time.time() + last_request = start_time + spinner = itertools.cycle(["|", "/", "-", "\\"]) + request_interval = 10 + + while True: + now = time.time() + elapsed_time = time.strftime("%H:%M:%S", time.gmtime(now - start_time)) + print( + f"\r {next(spinner)} Waiting to finish. Elapsed time: {elapsed_time}. " + f"Current state: status: `{status}`, conclusion: `{conclusion}`.", + end="", + ) + + time.sleep(0.3) + if (now - last_request) > request_interval: + last_request = now + run_data = get_single_workflow_run_data(run_id, repo_url, github_token) + status = safe_get(run_data, "status") + conclusion = safe_get(run_data, "conclusion") + if status in ["queued", "in_progress"]: + continue + elif status == "completed" and conclusion == "success": + print( + f"\rFinished in: {elapsed_time}. " + f"Last state: status: `{status}`, conclusion: `{conclusion}`.", + ) + return run_id + else: + print("\r") + raise Exception( + f"Run unsuccessful. Conclusion: {conclusion}. Check at: {run_web_url}" + ) + + +def prepare_directory(artifacts_dir): + """Creates given directory and asks for confirmation if directory exists before clearing it.""" + print(f"Preparing Artifacts directory: {artifacts_dir}") + if os.path.isdir(artifacts_dir): + question = ( + f"Found that directory already exists.\n" + f"Any existing content in it will be erased. Proceed?\n" + f"Your answer") + if get_yes_or_no_answer(question): + print(f"Clearing directory: {artifacts_dir}") + shutil.rmtree(artifacts_dir, ignore_errors=True) + else: + print("You said NO for clearing artifacts directory. Quitting ...") + sys.exit(1) + + os.makedirs(artifacts_dir) + + +def fetch_github_artifacts(run_id, repo_url, artifacts_dir, github_token): + """Downloads and extracts github artifacts with source dist and wheels from given run.""" + print("Starting downloading artifacts ... (it may take a while)") + run_data = get_single_workflow_run_data(run_id, repo_url, github_token) + artifacts_url = safe_get(run_data, "artifacts_url") + data_artifacts = request_url(artifacts_url, github_token) + artifacts = safe_get(data_artifacts, "artifacts", artifacts_url) + filtered_artifacts = [ + a for a in artifacts if ( + a["name"].startswith("source_zip") or + a["name"].startswith("wheelhouse")) + ] + for artifact in filtered_artifacts: + url = safe_get(artifact, "archive_download_url") + name = safe_get(artifact, "name") + size_in_bytes = safe_get(artifact, "size_in_bytes") + + with tempfile.TemporaryDirectory() as tmp: + temp_file_path = os.path.join(tmp, name + ".zip") + download_single_artifact( + url, name, size_in_bytes, temp_file_path, github_token) + extract_single_artifact(temp_file_path, artifacts_dir) + + +def download_single_artifact( + url, name, size_in_bytes, target_file_path, github_token): + artifacts_size_mb = round(size_in_bytes / (1024 * 1024), 2) + print( + f"\tDownloading {name}.zip artifact (size: {artifacts_size_mb} megabytes)" + ) + + with request_url(url, + github_token, + return_json=False, + allow_redirects=True, + stream=True) as r: + with open(target_file_path, "wb") as f: + shutil.copyfileobj(r.raw, f) + + +def extract_single_artifact(file_path, output_dir): + with zipfile.ZipFile(file_path, "r") as zip_ref: + print(f"\tUnzipping {len(zip_ref.filelist)} files") + zip_ref.extractall(output_dir) + + +if __name__ == "__main__": + print( + "Starting script for download GitHub Actions artifacts for Build Wheels workflow" + ) + ( + github_token, + user_github_id, + repo_url, + release_branch, + release_commit, + artifacts_dir, + ) = parse_arguments() + + try: + workflow_id = get_build_wheels_workflow_id(repo_url, github_token) + run_id = get_last_run_id( + workflow_id, repo_url, release_branch, release_commit, github_token) + validate_run(run_id, repo_url, github_token) + prepare_directory(artifacts_dir) + fetch_github_artifacts(run_id, repo_url, artifacts_dir, github_token) + print("Script finished successfully!") + print(f"Artifacts available in directory: {artifacts_dir}") + except KeyboardInterrupt as e: + print("\nScript cancelled. Quitting ...") diff --git a/release/src/main/scripts/sign_hash_python_wheels.sh b/release/src/main/scripts/sign_hash_python_wheels.sh deleted file mode 100755 index 05b703d2d86d..000000000000 --- a/release/src/main/scripts/sign_hash_python_wheels.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/bin/bash -# -# Licensed to the Apache Software Foundation (ASF) under one or more -# contributor license agreements. See the NOTICE file distributed with -# this work for additional information regarding copyright ownership. -# The ASF licenses this file to You under the Apache License, Version 2.0 -# (the "License"); you may not use this file except in compliance with -# the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -# This script will sign and hash python wheels. -set -e - -BEAM_SVN_DIR=https://dist.apache.org/repos/dist/dev/beam -VERSION= -PYTHON_ARTIFACTS_DIR=python - -echo "===============================Pre-requirements========================" -echo "Please make sure you have built python wheels." -echo "Please make sure you have configured and started your gpg by running ./preparation_before_release.sh." -echo "Do you want to proceed? [y|N]" -read confirmation -if [[ $confirmation != "y" ]]; then - echo "Please follow the release guide to build python wheels first." - exit -fi - -echo "[Input Required] Please enter the release version:" -read VERSION - -echo "================Listing all GPG keys=================" -gpg --list-keys --keyid-format LONG --fingerprint --fingerprint -echo "Please copy the public key which is associated with your Apache account:" - -read SIGNING_KEY - -cd ~ -if [[ -d ${VERSION} ]]; then - rm -rf ${VERSION} -fi - -svn co ${BEAM_SVN_DIR}/${VERSION} -cd ${VERSION}/${PYTHON_ARTIFACTS_DIR} - -echo "Fetch wheels artifacts" -gsutil cp -r gs://beam-wheels-staging/apache_beam-${VERSION}\*.whl . - -echo "Start signing and hashing python wheels artifacts" -rm *.whl.asc || true -rm *.whl.sha512 ||true -for artifact in *.whl; do - gpg --local-user ${SIGNING_KEY} --armor --detach-sig $artifact - sha512sum $artifact > ${artifact}.sha512 -done -svn add --force . -svn commit --no-auth-cache - -rm -rf ~/${VERSION} diff --git a/website/www/site/content/en/contribute/release-guide.md b/website/www/site/content/en/contribute/release-guide.md index 6e2f5512c86b..5768ae7c9379 100644 --- a/website/www/site/content/en/contribute/release-guide.md +++ b/website/www/site/content/en/contribute/release-guide.md @@ -562,7 +562,7 @@ For this step, we recommend you using automation script to create a RC, but you 1. Run gradle release to create rc tag and push source release into github repo. 1. Run gradle publish to push java artifacts into Maven staging repo. 1. Stage source release into dist.apache.org dev [repo](https://dist.apache.org/repos/dist/dev/beam/). - 1. Stage,sign and hash python binaries into dist.apache.ord dev repo python dir + 1. Stage, sign and hash python source distribution and wheels into dist.apache.org dev repo python dir 1. Stage SDK docker images to [docker hub Apache organization](https://hub.docker.com/search?q=apache%2Fbeam&type=image). 1. Create a PR to update beam-site, changes includes: * Copy python doc into beam-site @@ -595,12 +595,6 @@ For this step, we recommend you using automation script to create a RC, but you 1. Click the Close button. 1. When prompted for a description, enter “Apache Beam, version X, release candidate Y”. 1. Review all staged artifacts on https://repository.apache.org/content/repositories/orgapachebeam-NNNN/. They should contain all relevant parts for each module, including `pom.xml`, jar, test jar, javadoc, etc. Artifact names should follow [the existing format](https://search.maven.org/#search%7Cga%7C1%7Cg%3A%22org.apache.beam%22) in which artifact name mirrors directory structure, e.g., `beam-sdks-java-io-kafka`. Carefully review any new artifacts. - 1. Build and stage python wheels. - - There is a wrapper repo [beam-wheels](https://github.com/apache/beam-wheels) to help build python wheels. - - If you are interested in how it works, please refer to the [structure section](https://github.com/apache/beam-wheels#structure). - - Please follow the [user guide](https://github.com/apache/beam-wheels#user-guide) to build python wheels. - - Once all python wheels have been staged to GCS, - please run [./sign_hash_python_wheels.sh](https://github.com/apache/beam/blob/master/release/src/main/scripts/sign_hash_python_wheels.sh), which copies the wheels along with signatures and hashes to [dist.apache.org](https://dist.apache.org/repos/dist/dev/beam/). **********