diff --git a/.github/workflows/publish-docs-to-s3.yml b/.github/workflows/publish-docs-to-s3.yml index b039ff9674739..4deb0b9132b02 100644 --- a/.github/workflows/publish-docs-to-s3.yml +++ b/.github/workflows/publish-docs-to-s3.yml @@ -113,6 +113,7 @@ jobs: image-stash-ref: "publish-docs-${{ inputs.ref }}" registry-providers: ${{ steps.derive_registry_inputs.outputs.registry-providers }} registry-full-build: ${{ steps.derive_registry_inputs.outputs.registry-full-build }} + docs-kind: ${{ steps.docs_kind.outputs.docs-kind }} if: contains(fromJSON('[ "ashb", "bugraoz93", @@ -178,6 +179,26 @@ jobs: exit 0 fi python3 dev/registry/derive_wave_providers.py + - name: "Classify the published docs" + id: docs_kind + env: + INCLUDE_DOCS: ${{ inputs.include-docs }} + shell: bash + # Provider and Airflow docs land in different paths of the bucket, so the publish job + # serialises each kind separately. A full build or a mixed list counts as `airflow`. + # Same non-provider list as the registry step above. + run: | + NON_PROVIDER_TOKENS=("all" "apache-airflow" "apache-airflow-ctl" "apache-airflow-mypy" "task-sdk" "java-sdk" "ts-sdk" "helm-chart" "docker-stack") + kind=providers + for token in $INCLUDE_DOCS; do + for np in "${NON_PROVIDER_TOKENS[@]}"; do + if [[ "$token" == "$np" ]]; then + kind=airflow + fi + done + done + echo "Publishing ${kind} docs ('${INCLUDE_DOCS}')." + echo "docs-kind=${kind}" >> "${GITHUB_OUTPUT}" - name: "Derive the default Python version" id: default_python shell: bash @@ -585,6 +606,14 @@ jobs: needs.build-ts-sdk-docs.result == 'success' && needs.update-registry.result != 'failure' name: "Publish documentation to S3" + outputs: + docs-synced: ${{ steps.sync_docs.outcome == 'success' }} + # Keyed on the resolved destination rather than the `destination` input, whose `auto` stands + # for either bucket, and on the kind of docs, so provider and Airflow publishes do not wait + # for each other. The bucket-wide packages-metadata.json is left to update-packages-metadata. + concurrency: + group: publish-docs-${{ needs.build-info.outputs.destination }}-${{ needs.build-info.outputs.docs-kind }} + cancel-in-progress: false permissions: id-token: write contents: read @@ -697,6 +726,7 @@ jobs: aws-secret-access-key: ${{ secrets.DOCS_AWS_SECRET_ACCESS_KEY }} aws-region: us-east-2 - name: "Syncing docs to S3" + id: sync_docs env: DESTINATION_LOCATION: "${{ needs.build-info.outputs.destination-location }}" SOURCE_DIR_PATH: "/mnt/airflow-site/docs-archive/" @@ -705,7 +735,8 @@ jobs: run: | breeze release-management publish-docs-to-s3 --source-dir-path ${SOURCE_DIR_PATH} \ --destination-location ${DESTINATION_LOCATION} --stable-versions \ - --exclude-docs "${EXCLUDE_DOCS}" --overwrite ${SKIP_WRITE_TO_STABLE_FOLDER} + --exclude-docs "${EXCLUDE_DOCS}" --overwrite ${SKIP_WRITE_TO_STABLE_FOLDER} \ + --skip-packages-metadata - name: "Download schema artifacts" if: needs.build-info.outputs.publish-execution-api-schema == 'true' || needs.build-info.outputs.publish-supervisor-schema == 'true' || needs.build-info.outputs.publish-dag-schema == 'true' uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 @@ -735,6 +766,45 @@ jobs: breeze release-management publish-schemas-to-s3 \ --destination-location "${SCHEMAS_DESTINATION}" "${args[@]}" + update-packages-metadata: + needs: [build-info, publish-docs-to-s3] + # Keyed on the sync step rather than the job, so a schema publish failing after a complete sync + # still gets its docs into the metadata, while a partial sync is not advertised. + if: ${{ !cancelled() && needs.publish-docs-to-s3.outputs.docs-synced == 'true' }} + name: "Update packages metadata in S3" + # packages-metadata.json is rebuilt from a scan of every package in the bucket, so provider and + # Airflow publishes share it. One group per bucket keeps a scan from missing a version another + # run is still writing; a pending run cancelled by a later one loses nothing, as that later run + # rescans the whole bucket. + concurrency: + group: publish-docs-metadata-${{ needs.build-info.outputs.destination }} + cancel-in-progress: false + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: "Cleanup repo" + shell: bash + run: sudo rm -rf ${GITHUB_WORKSPACE}/* + - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: "Install Breeze" + uses: ./.github/actions/breeze + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 + with: + aws-access-key-id: ${{ secrets.DOCS_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.DOCS_AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-2 + - name: "Regenerate packages-metadata.json" + env: + DESTINATION_LOCATION: "${{ needs.build-info.outputs.destination-location }}" + run: > + breeze release-management publish-docs-to-s3 + --destination-location ${DESTINATION_LOCATION} --packages-metadata-only + update-registry: # Runs alongside the docs build rather than after it: the registry reads nothing the docs # build produces, so ordering the two only added its whole duration to the wait. The publish diff --git a/.github/workflows/registry-backfill.yml b/.github/workflows/registry-backfill.yml index e47631e2d19dc..6dd2967594a02 100644 --- a/.github/workflows/registry-backfill.yml +++ b/.github/workflows/registry-backfill.yml @@ -283,6 +283,12 @@ jobs: needs: [prepare, backfill] runs-on: ubuntu-latest name: "Publish versions.json" + # Shares registry-build.yml's group, so it never runs alongside a registry publish to the same + # bucket. Only this job: the backfill matrix writes per-version paths of its own, and putting + # a matrix in one group would cancel all but two of its entries. + concurrency: + group: registry-publish-${{ inputs.destination }} + cancel-in-progress: false steps: - name: "Checkout repository" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/registry-build.yml b/.github/workflows/registry-build.yml index afc3fa59ae3c8..7afc284682f60 100644 --- a/.github/workflows/registry-build.yml +++ b/.github/workflows/registry-build.yml @@ -93,6 +93,15 @@ jobs: build-and-publish-registry: timeout-minutes: 45 name: "Build & Publish Registry" + # An incremental build downloads the bucket's providers.json and modules.json, merges its own + # providers in and syncs them back, so two publishes to one bucket at once lose whichever + # finishes first. Shared with registry-backfill.yml's publish job; set on the job because this + # workflow is also called from publish-docs-to-s3.yml. GitHub keeps one pending run per group, + # so a third publish cancels the waiting one: that run shows as cancelled and is re-run, which + # beats the silent loss this replaces. + concurrency: + group: registry-publish-${{ inputs.destination }} + cancel-in-progress: false # On a dispatch this enforces the committer allowlist below; on the `workflow_call` path the # caller has already gated on its own allowlist, so this must not re-check it against a # second, separately-maintained copy. diff --git a/.github/workflows/release-constraints.yml b/.github/workflows/release-constraints.yml index faa75605bd5de..f1f1437c58f12 100644 --- a/.github/workflows/release-constraints.yml +++ b/.github/workflows/release-constraints.yml @@ -202,6 +202,11 @@ jobs: timeout-minutes: 20 name: "Publish and tag constraints" needs: [build-info, generate-constraints] + # Shared with the publish jobs of update-constraints-on-push.yml and + # update-constraints-on-push-stable.yml, so they never push to the same constraints branch at once. + concurrency: + group: constraints-publish-${{ needs.build-info.outputs.target-branch }} + cancel-in-progress: false permissions: contents: write packages: read diff --git a/.github/workflows/release_dockerhub_image.yml b/.github/workflows/release_dockerhub_image.yml index 4c65a55381588..fb81f83d8acb2 100644 --- a/.github/workflows/release_dockerhub_image.yml +++ b/.github/workflows/release_dockerhub_image.yml @@ -35,7 +35,7 @@ permissions: contents: read packages: read concurrency: - group: ${{ github.event.inputs.airflowVersion }} + group: release-dockerhub-image-${{ github.event.inputs.airflowVersion }} cancel-in-progress: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/update-constraints-on-push-stable.yml b/.github/workflows/update-constraints-on-push-stable.yml index 744ee4bc93fff..47379a8933dc6 100644 --- a/.github/workflows/update-constraints-on-push-stable.yml +++ b/.github/workflows/update-constraints-on-push-stable.yml @@ -119,6 +119,10 @@ jobs: timeout-minutes: 20 name: "Commit and push constraints" needs: [build-info, generate-constraints] + # Shared with the other constraints publishers, so they never push to one branch at once. + concurrency: + group: constraints-publish-${{ needs.build-info.outputs.default-constraints-branch }} + cancel-in-progress: false permissions: contents: write packages: read diff --git a/.github/workflows/update-constraints-on-push.yml b/.github/workflows/update-constraints-on-push.yml index 4c84567e23db5..4ffe806242fb2 100644 --- a/.github/workflows/update-constraints-on-push.yml +++ b/.github/workflows/update-constraints-on-push.yml @@ -210,6 +210,11 @@ jobs: timeout-minutes: 20 name: "Commit and push constraints" needs: [build-info, generate-constraints] + # Shared with the other constraints publishers. The baseline check below still decides + # whether this run may publish at all; this only stops the two pushes overlapping. + concurrency: + group: constraints-publish-${{ needs.build-info.outputs.default-constraints-branch }} + cancel-in-progress: false permissions: contents: write packages: read diff --git a/dev/breeze/doc/09_release_management_tasks.rst b/dev/breeze/doc/09_release_management_tasks.rst index 431784aeb16f7..2b69b30f11fae 100644 --- a/dev/breeze/doc/09_release_management_tasks.rst +++ b/dev/breeze/doc/09_release_management_tasks.rst @@ -1055,6 +1055,17 @@ To check what documents will be published to S3, you can use the ``--dry-run`` f breeze release-management publish-docs --source-dir-path /User/pavan/airflow-site/docs-archive --destination-location s3://airflow-docs/docs --dry-run +After the sync the command rebuilds ``manifest/packages-metadata.json`` from a scan of every package in +the bucket. When several publishes to one bucket can run at once, sync with ``--skip-packages-metadata`` +and rebuild the file in a separate, serialised step with ``--packages-metadata-only``, which needs no +``--source-dir-path``. The ``Publish Docs to S3`` workflow does this so that provider and Airflow docs +publishes can sync side by side. + +.. code-block:: bash + + breeze release-management publish-docs-to-s3 --destination-location s3://airflow-docs/docs \ + --packages-metadata-only + These are all available flags of ``release-management publish-docs-to-s3`` command: .. image:: ./images/output_release-management_publish-docs.svg diff --git a/dev/breeze/doc/images/output_release-management_publish-docs-to-s3.svg b/dev/breeze/doc/images/output_release-management_publish-docs-to-s3.svg index 915c4a99ff632..f3d633c015f3a 100644 --- a/dev/breeze/doc/images/output_release-management_publish-docs-to-s3.svg +++ b/dev/breeze/doc/images/output_release-management_publish-docs-to-s3.svg @@ -1,4 +1,4 @@ - + - + @@ -110,9 +110,24 @@ + + + + + + + + + + + + + + + - Command: release-management publish-docs-to-s3 + Command: release-management publish-docs-to-s3 @@ -128,22 +143,27 @@ Publishes docs to S3. ╭─ Publish docs to S3 ─────────────────────────────────────────────────────────────────────────────────────────────────╮ -│*--source-dir-path            Path to the directory with the generated documentation. [required](TEXT)│ -│*--destination-location       Name of the S3 bucket to publish the documentation to. [required]│ -│(s3://live-docs-airflow-apache-org/docs/ | │ -│s3://staging-docs-airflow-apache-org/docs/)│ -│--exclude-docs               Comma separated list of directories to exclude from the documentation. (TEXT)│ -│--dry-run                    Dry run - only print what would be done.│ -│--overwrite                  Overwrite existing files in the S3 bucket.│ -│--parallelism                Maximum number of processes to use while running the operation in parallel.       │ -│[default: 4](INTEGER RANGE 1<=x<=8)│ -│--stable-versions            Publish all the stable versions of the docs in the source directory.│ -│--publish-all-docs           Publish all the available docs in the source directory.│ -│--skip-write-to-stable-folderSkip writing stable versions folder.│ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Common options ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│--help-hShow this message and exit.│ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +│--source-dir-path            Path to the directory with the generated documentation. Required unless           │ +│--packages-metadata-only is used. (TEXT)│ +│*--destination-location       Name of the S3 bucket to publish the documentation to. [required]│ +│(s3://live-docs-airflow-apache-org/docs/ | │ +│s3://staging-docs-airflow-apache-org/docs/)│ +│--exclude-docs               Comma separated list of directories to exclude from the documentation. (TEXT)│ +│--dry-run                    Dry run - only print what would be done.│ +│--overwrite                  Overwrite existing files in the S3 bucket.│ +│--parallelism                Maximum number of processes to use while running the operation in parallel.       │ +│[default: 4](INTEGER RANGE 1<=x<=8)│ +│--stable-versions            Publish all the stable versions of the docs in the source directory.│ +│--publish-all-docs           Publish all the available docs in the source directory.│ +│--skip-write-to-stable-folderSkip writing stable versions folder.│ +│--skip-packages-metadata     Skip regenerating manifest/packages-metadata.json after the sync. Run the command │ +│again with --packages-metadata-only to regenerate it.                             │ +│--packages-metadata-only     Only regenerate manifest/packages-metadata.json from the docs already in the      │ +│bucket.                                                                           │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Common options ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│--help-hShow this message and exit.│ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/dev/breeze/doc/images/output_release-management_publish-docs-to-s3.txt b/dev/breeze/doc/images/output_release-management_publish-docs-to-s3.txt index bfad2d6f5eba3..7e1d18938f5c3 100644 --- a/dev/breeze/doc/images/output_release-management_publish-docs-to-s3.txt +++ b/dev/breeze/doc/images/output_release-management_publish-docs-to-s3.txt @@ -1 +1 @@ -b11847cc9e7091d948597ac5823c98d3 +042c33ea29854b37b9482ea09accedc6 diff --git a/dev/breeze/src/airflow_breeze/commands/release_management_commands.py b/dev/breeze/src/airflow_breeze/commands/release_management_commands.py index eea8d9ce16065..b2fede8d0dbf9 100644 --- a/dev/breeze/src/airflow_breeze/commands/release_management_commands.py +++ b/dev/breeze/src/airflow_breeze/commands/release_management_commands.py @@ -4828,8 +4828,9 @@ def generate_issue_content( @release_management_group.command(name="publish-docs-to-s3", help="Publishes docs to S3.") @click.option( "--source-dir-path", - help="Path to the directory with the generated documentation.", - required=True, + help="Path to the directory with the generated documentation. Required unless " + "--packages-metadata-only is used.", + default="", ) @click.option( "--exclude-docs", @@ -4865,6 +4866,17 @@ def generate_issue_content( is_flag=True, help="Skip writing stable versions folder.", ) +@click.option( + "--skip-packages-metadata", + is_flag=True, + help="Skip regenerating manifest/packages-metadata.json after the sync. " + "Run the command again with --packages-metadata-only to regenerate it.", +) +@click.option( + "--packages-metadata-only", + is_flag=True, + help="Only regenerate manifest/packages-metadata.json from the docs already in the bucket.", +) @option_parallelism def publish_docs_to_s3( source_dir_path: str, @@ -4876,12 +4888,23 @@ def publish_docs_to_s3( publish_all_docs: bool, stable_versions: bool, skip_write_to_stable_folder: bool, + skip_packages_metadata: bool, + packages_metadata_only: bool, ): from airflow_breeze.utils.publish_docs_to_s3 import S3DocsPublish if publish_all_docs and stable_versions: console_print("[error]You cannot use --publish-all and --stable-versions together[/]") sys.exit(1) + if packages_metadata_only and (publish_all_docs or stable_versions or skip_packages_metadata): + console_print( + "[error]You cannot use --packages-metadata-only with --publish-all-docs, " + "--stable-versions or --skip-packages-metadata[/]" + ) + sys.exit(1) + if not packages_metadata_only and not source_dir_path: + console_print("[error]--source-dir-path is required unless --packages-metadata-only is used[/]") + sys.exit(1) destination_location = destination_location.rstrip("/") source_dir_path = source_dir_path.rstrip("/") @@ -4899,7 +4922,10 @@ def publish_docs_to_s3( destination_location=destination_location, parallelism=parallelism, skip_write_to_stable_folder=skip_write_to_stable_folder, + skip_packages_metadata=skip_packages_metadata, ) + if packages_metadata_only: + docs_to_s3.generate_packages_metadata() if publish_all_docs: docs_to_s3.publish_all_docs() if stable_versions: diff --git a/dev/breeze/src/airflow_breeze/commands/release_management_commands_config.py b/dev/breeze/src/airflow_breeze/commands/release_management_commands_config.py index 305e1f01d930d..94de79045ac4d 100644 --- a/dev/breeze/src/airflow_breeze/commands/release_management_commands_config.py +++ b/dev/breeze/src/airflow_breeze/commands/release_management_commands_config.py @@ -585,6 +585,8 @@ "--stable-versions", "--publish-all-docs", "--skip-write-to-stable-folder", + "--skip-packages-metadata", + "--packages-metadata-only", ], } ], diff --git a/dev/breeze/src/airflow_breeze/utils/publish_docs_to_s3.py b/dev/breeze/src/airflow_breeze/utils/publish_docs_to_s3.py index 3158c2d42b0da..0b914fa7639b0 100644 --- a/dev/breeze/src/airflow_breeze/utils/publish_docs_to_s3.py +++ b/dev/breeze/src/airflow_breeze/utils/publish_docs_to_s3.py @@ -77,6 +77,7 @@ def __init__( overwrite: bool = False, parallelism: int = 1, skip_write_to_stable_folder: bool = False, + skip_packages_metadata: bool = False, ): self.source_dir_path = source_dir_path self.destination_location = destination_location @@ -86,6 +87,7 @@ def __init__( self.parallelism = parallelism self.source_dest_mapping: list[tuple[str, str]] = [] self.skip_write_to_stable_folder = skip_write_to_stable_folder + self.skip_packages_metadata = skip_packages_metadata @cached_property def get_all_docs(self): @@ -244,8 +246,8 @@ def run_publish(self): include_success_outputs=False, ) - # Now generate the packages-metadata.json - self.generate_packages_metadata() + if not self.skip_packages_metadata: + self.generate_packages_metadata() # Add redirects to package folders [ diff --git a/dev/breeze/tests/test_publish_docs_to_s3.py b/dev/breeze/tests/test_publish_docs_to_s3.py index b1b141269202f..814fd37f2f19f 100644 --- a/dev/breeze/tests/test_publish_docs_to_s3.py +++ b/dev/breeze/tests/test_publish_docs_to_s3.py @@ -273,3 +273,28 @@ def test_publish_all_docs( self.publish_docs_to_s3.publish_all_docs() assert self.publish_docs_to_s3.source_dest_mapping == expected_source_dest_mapping + + @pytest.mark.parametrize( + ("skip_packages_metadata", "expected_calls"), + [ + pytest.param(False, 1, id="generates_metadata"), + pytest.param(True, 0, id="skips_metadata"), + ], + ) + @patch.object(S3DocsPublish, "generate_packages_metadata") + @patch("airflow_breeze.utils.publish_docs_to_s3.check_async_run_results") + @patch("airflow_breeze.utils.publish_docs_to_s3.run_with_pool") + def test_run_publish_packages_metadata( + self, + mock_run_with_pool, + mock_check_async_run_results, + mock_generate_packages_metadata, + skip_packages_metadata, + expected_calls, + ): + mock_run_with_pool.return_value.__enter__.return_value = (MagicMock(), []) + self.publish_docs_to_s3.skip_packages_metadata = skip_packages_metadata + + self.publish_docs_to_s3.run_publish() + + assert mock_generate_packages_metadata.call_count == expected_calls diff --git a/dev/breeze/tests/test_release_management_commands.py b/dev/breeze/tests/test_release_management_commands.py index 68b4260844576..7eb67b1dde276 100644 --- a/dev/breeze/tests/test_release_management_commands.py +++ b/dev/breeze/tests/test_release_management_commands.py @@ -19,8 +19,10 @@ import subprocess from pathlib import Path from types import SimpleNamespace +from unittest import mock import pytest +from click.testing import CliRunner from airflow_breeze import global_constants from airflow_breeze.commands import release_management_commands @@ -35,9 +37,11 @@ get_prs_from_git_log_for_new_provider, get_suffix_from_package_in_dist, is_package_in_dist, + publish_docs_to_s3, ) from airflow_breeze.global_constants import DEFAULT_PYTHON_MAJOR_MINOR_VERSION from airflow_breeze.utils import shared_options +from airflow_breeze.utils.publish_docs_to_s3 import S3DocsPublish @pytest.mark.parametrize( @@ -414,3 +418,69 @@ def fake_run_command(cmd, **kwargs): shared_options.set_dry_run(False) assert ["git", "push", "upstream", "providers-amazon/9.29.0"] in commands + + +DOCS_DESTINATION = "s3://staging-docs-airflow-apache-org/docs/" + + +@mock.patch.object(S3DocsPublish, "publish_stable_version_docs", autospec=True) +@mock.patch.object(S3DocsPublish, "generate_packages_metadata", autospec=True) +def test_publish_docs_to_s3_packages_metadata_only(mock_generate_packages_metadata, mock_publish_stable): + result = CliRunner().invoke( + publish_docs_to_s3, + ["--destination-location", DOCS_DESTINATION, "--packages-metadata-only"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + mock_generate_packages_metadata.assert_called_once() + mock_publish_stable.assert_not_called() + + +@mock.patch.object(S3DocsPublish, "publish_stable_version_docs", autospec=True) +def test_publish_docs_to_s3_forwards_skip_packages_metadata(mock_publish_stable): + result = CliRunner().invoke( + publish_docs_to_s3, + [ + "--destination-location", + DOCS_DESTINATION, + "--source-dir-path", + "/tmp/docs-archive", + "--stable-versions", + "--skip-packages-metadata", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + (publisher,) = mock_publish_stable.call_args.args + assert publisher.skip_packages_metadata is True + + +@pytest.mark.parametrize( + "args", + [ + pytest.param(["--stable-versions"], id="source_dir_path_missing"), + pytest.param( + ["--packages-metadata-only", "--stable-versions"], id="metadata_only_with_stable_versions" + ), + pytest.param( + ["--packages-metadata-only", "--publish-all-docs"], id="metadata_only_with_publish_all_docs" + ), + pytest.param( + ["--packages-metadata-only", "--skip-packages-metadata"], id="metadata_only_with_skip_metadata" + ), + ], +) +@mock.patch.object(S3DocsPublish, "publish_stable_version_docs", autospec=True) +@mock.patch.object(S3DocsPublish, "publish_all_docs", autospec=True) +@mock.patch.object(S3DocsPublish, "generate_packages_metadata", autospec=True) +def test_publish_docs_to_s3_rejects_invalid_options( + mock_generate_packages_metadata, mock_publish_all, mock_publish_stable, args +): + result = CliRunner().invoke(publish_docs_to_s3, ["--destination-location", DOCS_DESTINATION, *args]) + + assert result.exit_code == 1 + mock_generate_packages_metadata.assert_not_called() + mock_publish_all.assert_not_called() + mock_publish_stable.assert_not_called()