scriptworker 63.0.0__tar.gz → 63.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {scriptworker-63.0.0 → scriptworker-63.1.0}/HISTORY.rst +17 -0
  2. {scriptworker-63.0.0 → scriptworker-63.1.0}/PKG-INFO +1 -1
  3. {scriptworker-63.0.0 → scriptworker-63.1.0}/pyproject.toml +18 -31
  4. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/artifacts.py +3 -0
  5. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/context.py +10 -0
  6. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/cot/verify.py +17 -9
  7. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/ed25519.py +4 -2
  8. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/github.py +2 -2
  9. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/task.py +11 -2
  10. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/task_process.py +1 -1
  11. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/utils.py +23 -10
  12. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/worker.py +1 -1
  13. {scriptworker-63.0.0 → scriptworker-63.1.0}/tox.ini +3 -12
  14. {scriptworker-63.0.0 → scriptworker-63.1.0}/.gitignore +0 -0
  15. {scriptworker-63.0.0 → scriptworker-63.1.0}/CONTRIBUTING.rst +0 -0
  16. {scriptworker-63.0.0 → scriptworker-63.1.0}/LICENSE +0 -0
  17. {scriptworker-63.0.0 → scriptworker-63.1.0}/README.rst +0 -0
  18. {scriptworker-63.0.0 → scriptworker-63.1.0}/renovate.json +0 -0
  19. {scriptworker-63.0.0 → scriptworker-63.1.0}/scripts/gen_ed25519_key.py +0 -0
  20. {scriptworker-63.0.0 → scriptworker-63.1.0}/scriptworker.yaml.tmpl +0 -0
  21. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/__init__.py +0 -0
  22. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/client.py +0 -0
  23. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/config.py +0 -0
  24. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/constants.py +0 -0
  25. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/cot/__init__.py +0 -0
  26. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/cot/generate.py +0 -0
  27. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/data/cot_v1_schema.json +0 -0
  28. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/data/scriptworker_task_schema.json +0 -0
  29. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/exceptions.py +0 -0
  30. {scriptworker-63.0.0 → scriptworker-63.1.0}/src/scriptworker/log.py +0 -0
@@ -4,6 +4,23 @@ Change Log
4
4
  All notable changes to this project will be documented in this file.
5
5
  This project adheres to `Semantic Versioning <http://semver.org/>`__.
6
6
 
7
+ 63.1.0 - 2026-05-26
8
+ -------------------
9
+
10
+ Added
11
+ ~~~~~
12
+ - The TASKCLUSTER_CREDENTIALS_FD environment variable is passed to scripts, and
13
+ the referenced file descriptor (a memfd) contains temporary taskcluster
14
+ credentials using the task's scopes, updated after each reclaimTask
15
+
16
+ 63.0.1 - 2026-04-27
17
+ -------------------
18
+
19
+ Fixed
20
+ ~~~~~
21
+
22
+ - File corruption caused by parallel downloads (#792)
23
+
7
24
  63.0.0 - 2026-04-09
8
25
  -------------------
9
26
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scriptworker
3
- Version: 63.0.0
3
+ Version: 63.1.0
4
4
  Summary: Taskcluster scriptworker
5
5
  Author-email: Mozilla Release Engineering <releng@mozilla.com>
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scriptworker"
3
- version = "63.0.0"
3
+ version = "63.1.0"
4
4
  description = "Taskcluster scriptworker"
5
5
  authors = [
6
6
  { name = "Mozilla Release Engineering", email = "releng@mozilla.com" },
@@ -43,11 +43,8 @@ dev = [
43
43
  "tox-uv",
44
44
  "aiohttp>=3",
45
45
  "asyncio_extras",
46
- "black",
47
46
  "coveralls",
48
- "flake8",
49
- "flake8_docstrings",
50
- "isort>=5",
47
+ "ruff",
51
48
  "mock",
52
49
  "pytest",
53
50
  "pytest-asyncio",
@@ -85,34 +82,24 @@ verify_cot = "scriptworker.cot.verify:verify_cot_cmdln"
85
82
  create_test_workdir = "scriptworker.cot.verify:create_test_workdir"
86
83
  verify_ed25519_signature = "scriptworker.ed25519:verify_ed25519_signature_cmdln"
87
84
 
88
- [tool.black]
85
+ [tool.ruff]
89
86
  line-length = 160
90
- target-version = ["py311", "py312", "py313"]
91
- include = '\.(wsgi|pyi?)$'
92
- exclude = '''
93
- /(
94
- \.eggs
95
- | \.git
96
- | \.hg
97
- | \.mypy_cache
98
- | \.tox
99
- | \.venv
100
- | \.cache
101
- | \.cache_py3
102
- | _build
103
- | buck-out
104
- | build
105
- | dist
106
- | ui
107
- )/
108
- '''
87
+ target-version = "py311"
88
+ extend-exclude = [
89
+ "docs"
90
+ ]
91
+
92
+ [tool.ruff.lint]
93
+ # F = pyflakes, E/W = pycodestyle, I = isort, D = pydocstyle
94
+ select = ["F", "E", "W", "I", "D"]
95
+
96
+ [tool.ruff.lint.per-file-ignores]
97
+ "tests/**" = ["D"]
98
+ "tests/test_github.py" = ["D", "E501"] # ignore long lines in test_github.py
99
+
109
100
 
110
- [tool.isort]
111
- multi_line_output = 3
112
- include_trailing_comma = true
113
- force_grid_wrap = 0
114
- use_parentheses = true
115
- line_length = 160
101
+ [tool.ruff.lint.pydocstyle]
102
+ convention = "pep257"
116
103
 
117
104
  [tool.pytest.ini_options]
118
105
  asyncio_mode = "auto"
@@ -225,6 +225,7 @@ def get_artifact_url(context, task_id, path):
225
225
 
226
226
  # list_latest_artifacts {{{1
227
227
  async def list_latest_artifacts(queue, task_id):
228
+ """List all artifacts for the latest run of a task, following pagination."""
228
229
  artifacts = []
229
230
 
230
231
  def pagination_handler(response):
@@ -235,6 +236,7 @@ async def list_latest_artifacts(queue, task_id):
235
236
 
236
237
 
237
238
  async def retry_list_latest_artifacts(queue, task_id, exception=TaskclusterFailure, **kwargs):
239
+ """Retry ``list_latest_artifacts`` on transient Taskcluster failures."""
238
240
  kwargs.setdefault("retry_exceptions", tuple(set([TaskclusterFailure, exception])))
239
241
  return await retry_async(list_latest_artifacts, args=(queue, task_id), **kwargs)
240
242
 
@@ -442,6 +444,7 @@ def assert_is_parent(path, parent_dir):
442
444
 
443
445
 
444
446
  def get_artifacts_matching_glob(context, task_id, pattern):
447
+ """Return paths of downloaded artifacts matching ``pattern`` under the task's cot/<id> dir."""
445
448
  parent_dir = os.path.abspath(os.path.join(context.config["work_dir"], "cot", task_id))
446
449
  matching = []
447
450
  for root, _, files in os.walk(parent_dir):
@@ -59,6 +59,7 @@ class Context(object):
59
59
 
60
60
  config: Optional[Dict[str, Any]] = None
61
61
  credentials_timestamp: Optional[int] = None
62
+ credentials_fd: int = -1
62
63
  proc: Optional[task_process.TaskProcess] = None
63
64
  queue: Optional[Queue] = None
64
65
  session: Optional[aiohttp.ClientSession] = None
@@ -98,6 +99,8 @@ class Context(object):
98
99
  if claim_task:
99
100
  self.task = claim_task["task"]
100
101
  self.verify_task()
102
+ # flags=0 to let the child inherit this fd
103
+ self.credentials_fd = os.memfd_create("scriptworker_temp_creds", flags=0)
101
104
  self.temp_credentials = claim_task["credentials"]
102
105
  path = os.path.join(self.config["work_dir"], "task.json")
103
106
  assert self.task
@@ -105,6 +108,8 @@ class Context(object):
105
108
  else:
106
109
  self.temp_credentials = None
107
110
  self.task = None
111
+ os.close(self.credentials_fd)
112
+ self.credentials_fd = -1
108
113
 
109
114
  def verify_task(self) -> None:
110
115
  """Run some task sanity checks on ``self.task``."""
@@ -193,6 +198,10 @@ class Context(object):
193
198
  def temp_credentials(self, credentials: Optional[Dict[str, Any]]) -> None:
194
199
  self._temp_credentials = credentials
195
200
  self.temp_queue = self.create_queue(self.temp_credentials)
201
+ if credentials:
202
+ data = json.dumps(credentials, indent=2, sort_keys=True).encode("ascii")
203
+ # use pwrite so we don't confuse the child by changing the file offset
204
+ assert os.pwrite(self.credentials_fd, data, 0) == len(data)
196
205
 
197
206
  def write_json(self, path: str, contents: Dict[str, Any], message: str) -> None:
198
207
  """Write json to disk.
@@ -261,6 +270,7 @@ class Context(object):
261
270
 
262
271
  @property
263
272
  def download_semaphore(self) -> asyncio.BoundedSemaphore:
273
+ """Return a lazily-built semaphore limiting concurrent downloads to ``max_concurrent_downloads``."""
264
274
  assert self.config
265
275
  if self._download_semaphore is None:
266
276
  try:
@@ -180,7 +180,8 @@ class ChainOfTrust(object):
180
180
 
181
181
  def is_scope_in_restricted_scopes(self, scope, restricted_scopes):
182
182
  """Determine if a scope matches in a list of restricted_scopes.
183
- if one of the restricted scopes ends with '*', find a partial match.
183
+
184
+ If one of the restricted scopes ends with '*', find a partial match.
184
185
 
185
186
  Returns:
186
187
  String: string of matching restricted_scope, if no match ""
@@ -728,8 +729,10 @@ async def download_cot_artifact(chain, task_id, path):
728
729
  link = chain.get_link(task_id)
729
730
  log.debug("Verifying {} is in {} cot artifacts...".format(path, task_id))
730
731
  if not link.cot:
731
- log.warning('Chain of Trust for "{}" in {} does not exist. See above log for more details. \
732
- Skipping download of this artifact'.format(path, task_id))
732
+ log.warning(
733
+ 'Chain of Trust for "{}" in {} does not exist. See above log for more details. \
734
+ Skipping download of this artifact'.format(path, task_id)
735
+ )
733
736
  return
734
737
 
735
738
  if path not in link.cot["artifacts"]:
@@ -1094,8 +1097,9 @@ async def _get_additional_hg_push_jsone_context(parent_link, decision_link):
1094
1097
  allowed_comments.append(push_comment[try_idx:].split("\n")[0])
1095
1098
  if decision_comment not in allowed_comments:
1096
1099
  raise CoTError(
1097
- "Decision task {} comment doesn't match the push comment!\n"
1098
- "Decision comment: \n{}\nPush comment: \n{}".format(decision_link.name, decision_comment, push_comment)
1100
+ "Decision task {} comment doesn't match the push comment!\nDecision comment: \n{}\nPush comment: \n{}".format(
1101
+ decision_link.name, decision_comment, push_comment
1102
+ )
1099
1103
  )
1100
1104
  return {
1101
1105
  "push": {
@@ -2113,7 +2117,8 @@ def verify_cot_cmdln(args=None, event_loop=None):
2113
2117
 
2114
2118
  """
2115
2119
  args = args or sys.argv[1:]
2116
- parser = argparse.ArgumentParser(description="""Verify a given task's chain of trust.
2120
+ parser = argparse.ArgumentParser(
2121
+ description="""Verify a given task's chain of trust.
2117
2122
 
2118
2123
  Given a task's `task_id`, get its task definition, then trace its chain of
2119
2124
  trust back to the tree. This doesn't verify chain of trust artifact signatures,
@@ -2125,7 +2130,8 @@ To use, first either set your taskcluster creds in your env http://bit.ly/2eDMa6
2125
2130
  or in the CREDS_FILES http://bit.ly/2fVMu0A
2126
2131
 
2127
2132
  If you are verifying against a private github repo, please also set in environment
2128
- SCRIPTWORKER_GITHUB_OAUTH_TOKEN to an OAUTH token with read permissions to the repo""")
2133
+ SCRIPTWORKER_GITHUB_OAUTH_TOKEN to an OAUTH token with read permissions to the repo"""
2134
+ )
2129
2135
  parser.add_argument("task_id", help="the task id to test")
2130
2136
  parser.add_argument("--task-type", help="the task type to test", choices=sorted(get_valid_task_types().keys()), required=True)
2131
2137
  parser.add_argument("--cleanup", help="clean up the temp dir afterwards", dest="cleanup", action="store_true", default=False)
@@ -2192,13 +2198,15 @@ def create_test_workdir(args=None, event_loop=None):
2192
2198
 
2193
2199
  """
2194
2200
  args = args or sys.argv[1:]
2195
- parser = argparse.ArgumentParser(description="""Populate a test `work_dir`.
2201
+ parser = argparse.ArgumentParser(
2202
+ description="""Populate a test `work_dir`.
2196
2203
 
2197
2204
  Given a scriptworker task's `task_id`, get its task definition, write it to
2198
2205
  `./work/task.json`, then download its `upstreamArtifacts` and put them in
2199
2206
  `./work/cot/TASK_ID/PATH`.
2200
2207
 
2201
- This is helpful in manually testing a *script run.""")
2208
+ This is helpful in manually testing a *script run."""
2209
+ )
2202
2210
  parser.add_argument("--path", help="relative path to the work_dir", default="work")
2203
2211
  parser.add_argument("--overwrite", help="overwrite an existing work_dir", action="store_true")
2204
2212
  parser.add_argument("task_id", help="the task id to test")
@@ -141,11 +141,13 @@ def verify_ed25519_signature_cmdln(args=None, exception=SystemExit):
141
141
 
142
142
  """
143
143
  args = args or sys.argv[1:]
144
- parser = argparse.ArgumentParser(description="""Verify an ed25519 signature from the command line.
144
+ parser = argparse.ArgumentParser(
145
+ description="""Verify an ed25519 signature from the command line.
145
146
 
146
147
  Given a file and its detached signature, verify that it has been signed with
147
148
  a valid key. This key can be specified on the command line; otherwise we'll
148
- default to ``config['ed25519_public_keys']``.""")
149
+ default to ``config['ed25519_public_keys']``."""
150
+ )
149
151
  parser.add_argument("--pubkey", help="path to a base64-encoded ed25519 pubkey, optional")
150
152
  parser.add_argument("file_path")
151
153
  parser.add_argument("sig_path")
@@ -121,7 +121,7 @@ class GitHubRepository:
121
121
  if any(vcs_rule.get("require_secret") for vcs_rule in context.config["trusted_vcs_rules"]):
122
122
  # This check uses unofficial API on github, which we can't easily
123
123
  # check for private repos, assume its true in the private case.
124
- log.info("has_commit_landed_on_repository() not implemented for private" "repositories, assume True")
124
+ log.info("has_commit_landed_on_repository() not implemented for private repositories, assume True")
125
125
  return True
126
126
 
127
127
  # Revision may be a tag name. `branch_commits` doesn't work on tags
@@ -293,7 +293,7 @@ def is_github_repo_owner_the_official_one(context, repo_owner):
293
293
  official_repo_owner = context.config["official_github_repos_owner"]
294
294
  if not official_repo_owner:
295
295
  raise ConfigError(
296
- "This worker does not have a defined owner for official GitHub repositories. " 'Given "official_github_repos_owner": {}'.format(official_repo_owner)
296
+ 'This worker does not have a defined owner for official GitHub repositories. Given "official_github_repos_owner": {}'.format(official_repo_owner)
297
297
  )
298
298
 
299
299
  return official_repo_owner == repo_owner
@@ -117,7 +117,7 @@ def get_run_id(claim_task):
117
117
 
118
118
  # get_task_maxruntime {{{1
119
119
  def get_task_maxruntime(task_def, max_timeout):
120
- """Given a task definition, return the lower of max_timeout and maxRunTime if set"""
120
+ """Given a task definition, return the lower of max_timeout and maxRunTime if set."""
121
121
  max_run_time = task_def["payload"].get("maxRunTime")
122
122
  if max_run_time is None:
123
123
  return max_timeout
@@ -672,7 +672,16 @@ async def run_task(context, to_cancellable_process):
672
672
  env["TASK_ID"] = context.task_id or "None"
673
673
  env["RUN_ID"] = str(get_run_id(context.claim_task))
674
674
  env["TASKCLUSTER_ROOT_URL"] = context.config["taskcluster_root_url"]
675
- kwargs = {"stdout": PIPE, "stderr": PIPE, "stdin": None, "close_fds": True, "preexec_fn": lambda: os.setsid(), "env": env} # pragma: no branch
675
+ env["TASKCLUSTER_CREDENTIALS_FD"] = str(context.credentials_fd)
676
+ kwargs = {
677
+ "stdout": PIPE,
678
+ "stderr": PIPE,
679
+ "stdin": None,
680
+ "close_fds": True,
681
+ "preexec_fn": lambda: os.setsid(),
682
+ "env": env,
683
+ "pass_fds": (context.credentials_fd,),
684
+ } # pragma: no branch
676
685
 
677
686
  timeout = get_task_maxruntime(context.task, context.config["task_max_timeout"])
678
687
  subprocess = await asyncio.create_subprocess_exec(*context.config["task_script"], **kwargs)
@@ -19,7 +19,7 @@ class TaskProcess:
19
19
  """Wraps worker task's process."""
20
20
 
21
21
  def __init__(self, process: Process):
22
- """Constructor.
22
+ """Initialize the wrapper around the worker task's subprocess.
23
23
 
24
24
  Args:
25
25
  process (Process): task process
@@ -16,6 +16,7 @@ import random
16
16
  import re
17
17
  import shutil
18
18
  import time
19
+ import uuid
19
20
  from copy import deepcopy
20
21
  from typing import IO, TYPE_CHECKING, Any, Awaitable, Callable, Dict, Match, Optional, Sequence, Tuple, Type, Union, cast, overload # noqa
21
22
  from urllib.parse import unquote, urlparse
@@ -41,6 +42,7 @@ log = logging.getLogger(__name__)
41
42
 
42
43
  # scriptworker_session {{{1
43
44
  def scriptworker_session(*args, **kwargs):
45
+ """Return an ``aiohttp.ClientSession`` with the scriptworker User-Agent header set."""
44
46
  kwargs.setdefault("headers", {}).setdefault("User-Agent", f"scriptworker {scriptworker.__version__}")
45
47
  return aiohttp.ClientSession(*args, **kwargs)
46
48
 
@@ -680,12 +682,21 @@ async def download_file(context, url, abs_filename, session=None, chunk_size=128
680
682
  await _log_download_error(resp, "Failed to download %(url)s: %(status)s; body=%(body)s")
681
683
  raise DownloadError("{} status {} is not 200!".format(loggable_url, resp.status))
682
684
  makedirs(parent_dir)
683
- with open(abs_filename, "wb") as fd:
684
- while True:
685
- chunk = await resp.content.read(chunk_size)
686
- if not chunk:
687
- break
688
- fd.write(chunk)
685
+ tmp_filename = "{}.{}.part".format(abs_filename, uuid.uuid4().hex)
686
+ try:
687
+ with open(tmp_filename, "wb") as fd:
688
+ while True:
689
+ chunk = await resp.content.read(chunk_size)
690
+ if not chunk:
691
+ break
692
+ fd.write(chunk)
693
+ os.replace(tmp_filename, abs_filename)
694
+ except Exception:
695
+ try:
696
+ os.unlink(tmp_filename)
697
+ except OSError:
698
+ pass
699
+ raise
689
700
  log.info("Done")
690
701
 
691
702
 
@@ -865,8 +876,9 @@ def remove_empty_keys(values, remove=({}, None, [], "null")):
865
876
 
866
877
  # add_projectid {{{1
867
878
  def add_projectid(task_def):
868
- """Add a projectId property to a task, if none already exists, using
869
- the Taskcluster default value of 'none'.
879
+ """Add a projectId property to a task, if none already exists.
880
+
881
+ Uses the Taskcluster default value of 'none'.
870
882
 
871
883
  Args:
872
884
  task_def (dict): the task definition
@@ -879,8 +891,9 @@ def add_projectid(task_def):
879
891
 
880
892
  # add_taskqueueid {{{1
881
893
  def add_taskqueueid(task_def):
882
- """Add a taskQueueId property to a task, if none already exists, based
883
- on the provisionerId and workerType. Then remove those two properties.
894
+ """Add a taskQueueId property to a task, if none already exists.
895
+
896
+ Builds the value from the provisionerId and workerType, then removes those two properties.
884
897
 
885
898
  Args:
886
899
  task_def (dict): the task definition
@@ -104,7 +104,7 @@ class RunTasks:
104
104
  """Manages processing of Taskcluster tasks."""
105
105
 
106
106
  def __init__(self):
107
- """Constructor."""
107
+ """Initialize the task-processing state."""
108
108
  self.future = None
109
109
  self.task_process = None
110
110
  self.is_cancelled = False
@@ -54,25 +54,16 @@ commands=
54
54
 
55
55
  [testenv:check]
56
56
  skip_install = true
57
+ deps = ruff
57
58
  commands =
58
- black --check {toxinidir}
59
- isort --check --df {toxinidir}
60
- flake8 {toxinidir}
59
+ ruff check {toxinidir}
60
+ ruff format --check {toxinidir}
61
61
 
62
62
  [testenv:mypy]
63
63
  commands =
64
64
  mypy --config {toxinidir}/mypi.ini {toxinidir}/src
65
65
 
66
66
 
67
- [flake8]
68
- max-line-length = 160
69
- # test_github.py ignored because of https://gitlab.com/pycqa/flake8/issues/375
70
- exclude = .ropeproject,.tox,sandbox,docs,.eggs,*.egg,*.egg-info,setup.py,build/,tests/test_github.py,.venv
71
- per-file-ignores =
72
- # Ignore documentation issues in tests
73
- tests:*:D
74
- show-source = True
75
-
76
67
  [pytest]
77
68
  norecursedirs = .tox .git .hg sandbox .eggs build
78
69
  python_files = test_*.py
File without changes
File without changes
File without changes