Skip to content

Commit e5c1d5b

Browse files
humitosagjohnsonbenjaomingericholscher
authored
Build: skip build based on commands' exit codes (#9649)
* Build: skip build based on commands' exit codes Define a particular exit code (439) to skip a build. If any of the commands returns this exit code, the build will be cancelled automatically and won't run any of the following commands. When this happens, the build will be marked as `cancelled` and no email/webhook notifications will be sent. Why 439 was chosen for the exit code? It's the word "skip" encoded in ASCII. Then it's taken the 256 modulo of it because the Unix implementation does this automatically for exit codes greater than 255 ``` >>> sum(list('skip'.encode('ascii'))) 439 >>> 439 % 256 183 ``` * Update readthedocs/doc_builder/exceptions.py Co-authored-by: Anthony <[email protected]> * Build: send SUCCESS external build status on skipped builds When the command exists with the magic exit code, we send SUCCESS to GitHub/GitLab as status so the PR check passes and the PR can be merged. * Docs: skip build based on a condition * Lint: small darker change * Docs: reference fixed * Docs: improve comment on YAML * Build: make usage of `status` variable Thanks to Benjamin for pointing it to me ;) * Apply suggestions from code review Co-authored-by: Benjamin Balder Bach <[email protected]> * Docs: update code example for skip builds Use Bash's `if` to only run this code on pull requests. * Docs: example showing how to skip the build based on commit message * Docs: refactor "skipping a build" section (#9717) * Docs: refactor "skipping a build" section - Move the explanation about the "Cancelling builds" feature to the "Builds" page - Keep the examples (How-To) for "Cancel a build" into the "Build customization" page * Docs: use 183 instead of 439 exit code > This error code isn't a valid exit > code (tldp.org/LDP/abs/html/exitcodes.html), we shouldn't document a code above > 255 otherwise users will get confused. From Eric's review. * Apply suggestions from code review Co-authored-by: Eric Holscher <[email protected]> * Minor fix underline * Use multi-line bash examples They are easier to read * Update docs/user/builds.rst Co-authored-by: Eric Holscher <[email protected]> Co-authored-by: Eric Holscher <[email protected]> * Docs: use `grep` instead of `case` * Docs: small note about "Why 183?" Co-authored-by: Anthony <[email protected]> Co-authored-by: Benjamin Balder Bach <[email protected]> Co-authored-by: Eric Holscher <[email protected]>
1 parent 76503b9 commit e5c1d5b

File tree

6 files changed

+139
-7
lines changed

6 files changed

+139
-7
lines changed

docs/user/build-customization.rst

+65
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ There are some caveats to knowing when using user-defined jobs:
7373
* Environment variables are expanded in the commands (see :doc:`environment-variables`)
7474
* Each command is executed in a new shell process, so modifications done to the shell environment do not persist between commands
7575
* Any command returning non-zero exit code will cause the build to fail immediately
76+
(note there is a special exit code to `cancel the build <cancel-build-based-on-a-condition>`_)
7677
* ``build.os`` and ``build.tools`` are required when using ``build.jobs``
7778

7879

@@ -104,6 +105,70 @@ To avoid this, it's possible to unshallow the clone done by Read the Docs:
104105
- git fetch --unshallow
105106
106107
108+
Cancel build based on a condition
109+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
110+
111+
When a command exits with code ``183``,
112+
Read the Docs will cancel the build immediately.
113+
You can use this approach to cancel builds that you don't want to complete based on some conditional logic.
114+
115+
.. note:: Why 183 was chosen for the exit code?
116+
117+
It's the word "skip" encoded in ASCII.
118+
Then it's taken the 256 modulo of it because
119+
`the Unix implementation does this automatically <https://tldp.org/LDP/abs/html/exitcodes.html>`_
120+
for exit codes greater than 255.
121+
122+
.. code-block:: python
123+
124+
>>> sum(list('skip'.encode('ascii')))
125+
439
126+
>>> 439 % 256
127+
183
128+
129+
130+
Here is an example that cancels builds from pull requests when there are no changes to the ``docs/`` folder compared to the ``origin/main`` branch:
131+
132+
.. code-block:: yaml
133+
:caption: .readthedocs.yaml
134+
135+
version: 2
136+
build:
137+
os: "ubuntu-22.04"
138+
tools:
139+
python: "3.11"
140+
jobs:
141+
post_checkout:
142+
# Cancel building pull requests when there aren't changed in the docs directory.
143+
# `--quiet` exits with a 1 when there **are** changes,
144+
# so we invert the logic with a !
145+
#
146+
# If there are no changes (exit 0) we force the command to return with 183.
147+
# This is a special exit code on Read the Docs that will cancel the build immediately.
148+
- |
149+
if [ $READTHEDOCS_VERSION_TYPE = "external" ];
150+
then
151+
! git diff --quiet origin/main -- docs/ && exit 183;
152+
fi
153+
154+
155+
This other example shows how to cancel a build if the commit message contains ``skip ci`` on it:
156+
157+
.. code-block:: yaml
158+
:caption: .readthedocs.yaml
159+
160+
version: 2
161+
build:
162+
os: "ubuntu-22.04"
163+
tools:
164+
python: "3.11"
165+
jobs:
166+
post_checkout:
167+
# Use `git log` to check if the latest commit contains "skip ci",
168+
# in that case exit the command with 183 to cancel the build
169+
- (git --no-pager log --pretty="tformat:%s -- %b" -1 | grep -viq "skip ci") || exit 183
170+
171+
107172
Generate documentation from annotated sources with Doxygen
108173
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
109174

docs/user/builds.rst

+40
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,46 @@ The following are the pre-defined jobs executed by Read the Docs:
6868
it's possible to run user-defined commands and :doc:`customize the build process <build-customization>`.
6969

7070

71+
When to cancel builds
72+
---------------------
73+
74+
There may be situations where you want to cancel a particular running build.
75+
Cancelling running builds will allow your team to speed up review times and also help us reduce server costs and ultimately,
76+
our environmental footprint.
77+
78+
Consider the following scenarios:
79+
80+
* the build has an external dependency that hasn't been updated
81+
* there were no changes on the documentation files
82+
* many other use cases that can be solved with custom logic
83+
84+
For these scenarios,
85+
Read the Docs supports three different mechanisms to cancel a running build:
86+
87+
:Manually:
88+
89+
Once a build was triggered,
90+
project administrators can go to the build detail page
91+
and click the button "Cancel build".
92+
93+
:Automatically:
94+
95+
When Read the Docs detects a push to a branch that it's currently building the documentation,
96+
it cancels the running build and start a new build using the latest commit from the new push.
97+
98+
:Programatically:
99+
100+
You can use user-defined commands on ``build.jobs`` or ``build.commands`` (see :doc:`build-customization`)
101+
to check for a condition and exit it with the code ``183`` if you want to cancel the running build or ``0``, otherwise.
102+
103+
In this case, Read the Docs will communicate to your Git platform (GitHub/GitLab) that the build succeeded (green tick ✅)
104+
so the pull request is in a mergeable state.
105+
106+
.. tip::
107+
108+
Take a look at :ref:`build-customization:cancel build based on a condition` section for some examples.
109+
110+
71111
Build resources
72112
---------------
73113

readthedocs/doc_builder/constants.py

+9-3
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
1-
# -*- coding: utf-8 -*-
21

32
"""Doc build constants."""
43

5-
import structlog
64
import re
75

6+
import structlog
87
from django.conf import settings
98

10-
119
log = structlog.get_logger(__name__)
1210

1311
PDF_RE = re.compile('Output written on (.*?)')
@@ -30,3 +28,11 @@
3028
DOCKER_OOM_EXIT_CODE = 137
3129

3230
DOCKER_HOSTNAME_MAX_LEN = 64
31+
32+
# Why 183 exit code?
33+
#
34+
# >>> sum(list('skip'.encode('ascii')))
35+
# 439
36+
# >>> 439 % 256
37+
# 183
38+
RTD_SKIP_BUILD_EXIT_CODE = 183

readthedocs/doc_builder/environments.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@
3030
DOCKER_SOCKET,
3131
DOCKER_TIMEOUT_EXIT_CODE,
3232
DOCKER_VERSION,
33+
RTD_SKIP_BUILD_EXIT_CODE,
3334
)
34-
from .exceptions import BuildAppError, BuildUserError
35+
from .exceptions import BuildAppError, BuildUserError, BuildUserSkip
3536

3637
log = structlog.get_logger(__name__)
3738

@@ -468,6 +469,8 @@ def run_command_class(
468469
project_slug=self.project.slug if self.project else '',
469470
version_slug=self.version.slug if self.version else '',
470471
)
472+
elif build_cmd.exit_code == RTD_SKIP_BUILD_EXIT_CODE:
473+
raise BuildUserSkip()
471474
else:
472475
# TODO: for now, this still outputs a generic error message
473476
# that is the same across all commands. We could improve this

readthedocs/doc_builder/exceptions.py

+5
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ class BuildUserError(BuildBaseException):
4242
)
4343

4444

45+
class BuildUserSkip(BuildUserError):
46+
message = gettext_noop("This build was manually skipped using a command exit code.")
47+
state = BUILD_STATE_CANCELLED
48+
49+
4550
class ProjectBuildsSkippedError(BuildUserError):
4651
message = gettext_noop('Builds for this project are temporarily disabled')
4752

readthedocs/projects/tasks/builds.py

+16-3
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
BuildCancelled,
4545
BuildMaxConcurrencyError,
4646
BuildUserError,
47+
BuildUserSkip,
4748
MkDocsYAMLParseError,
4849
ProjectBuildsSkippedError,
4950
YAMLParseError,
@@ -280,6 +281,7 @@ class UpdateDocsTask(SyncRepositoryMixin, Task):
280281
YAMLParseError,
281282
BuildCancelled,
282283
BuildUserError,
284+
BuildUserSkip,
283285
RepositoryError,
284286
MkDocsYAMLParseError,
285287
ProjectConfigurationError,
@@ -289,6 +291,7 @@ class UpdateDocsTask(SyncRepositoryMixin, Task):
289291
exceptions_without_notifications = (
290292
BuildCancelled,
291293
BuildMaxConcurrencyError,
294+
BuildUserSkip,
292295
ProjectBuildsSkippedError,
293296
)
294297

@@ -450,8 +453,8 @@ def on_failure(self, exc, task_id, args, kwargs, einfo):
450453
)
451454
# Known errors in the user's project (e.g. invalid config file, invalid
452455
# repository, command failed, etc). Report the error back to the user
453-
# using the `message` attribute from the exception itself. Otherwise,
454-
# use a generic message.
456+
# using the `message` and `state` attributes from the exception itself.
457+
# Otherwise, use a generic message and default state.
455458
elif isinstance(exc, BuildUserError):
456459
if hasattr(exc, 'message') and exc.message is not None:
457460
self.data.build['error'] = exc.message
@@ -490,11 +493,21 @@ def on_failure(self, exc, task_id, args, kwargs, einfo):
490493
version_type = None
491494
if self.data.version:
492495
version_type = self.data.version.type
496+
497+
# NOTE: autoflake gets confused here. We need the NOQA for now.
498+
status = BUILD_STATUS_FAILURE
499+
if isinstance(exc, BuildUserSkip):
500+
# The build was skipped by returning the magic exit code,
501+
# marked as CANCELLED, but communicated to GitHub as successful.
502+
# This is because the PR has to be available for merging when the build
503+
# was skipped on purpose.
504+
status = BUILD_STATUS_SUCCESS
505+
493506
send_external_build_status(
494507
version_type=version_type,
495508
build_pk=self.data.build['id'],
496509
commit=self.data.build_commit,
497-
status=BUILD_STATUS_FAILURE,
510+
status=status,
498511
)
499512

500513
# Update build object

0 commit comments

Comments
 (0)