fix(ci): better logs on bundle builds
This commit is contained in:
15
.github/workflows/desktop-bundled-release.yml
vendored
15
.github/workflows/desktop-bundled-release.yml
vendored
@@ -212,6 +212,21 @@ jobs:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Print the release.py command for this run
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
BUILD_COMMIT: ${{ inputs.build_commit }}
|
||||
CHANNEL: ${{ inputs.channel }}
|
||||
DISPOSABLE_CHANNEL: ${{ inputs.disposable_channel }}
|
||||
DISPOSABLE_RECEIVERS: ${{ inputs.disposable_receivers }}
|
||||
RELEASE_PHASE: ${{ inputs.release-phase }}
|
||||
UPLOAD_RELEASE: ${{ inputs.upload_release }}
|
||||
TERMUX_ONLY: ${{ inputs.termux_only }}
|
||||
TERMUX_UPGRADE_FROM_TAG: ${{ inputs.termux_upgrade_from_tag }}
|
||||
BUNDLE_ENV_JSON: ${{ inputs.bundle_env }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: python3 -m scripts.releases.dispatch_log
|
||||
|
||||
- name: Archive every pinned input
|
||||
# Runner Python is intentional: the toolchain's upstream may be gone.
|
||||
# Same checkout contract the standalone archive job had: the tag ref
|
||||
|
||||
@@ -57,6 +57,8 @@ class R2ChannelStore:
|
||||
# Lost success responses and conditional retries are settled by exact bytes.
|
||||
current = self.get(key)
|
||||
if current is not None and current[0] == body:
|
||||
from scripts.releases.upload_summary import note
|
||||
note(self.scope.key(key))
|
||||
return
|
||||
if isinstance(exc, r2.R2RequestError) and exc.status == 412:
|
||||
raise ChannelConflict(f"Channel write conflict: {key}") from exc
|
||||
@@ -64,6 +66,8 @@ class R2ChannelStore:
|
||||
current = self.get(key)
|
||||
if current is None or current[0] != body:
|
||||
raise ChannelConflict(f"Channel changed before authenticated readback: {key}")
|
||||
from scripts.releases.upload_summary import note
|
||||
note(self.scope.key(key))
|
||||
|
||||
def keys(self, prefix: str) -> list[str]:
|
||||
keys, seen = [], set()
|
||||
|
||||
171
scripts/releases/dispatch_log.py
Normal file
171
scripts/releases/dispatch_log.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Print the release.py command and receipt identity for this bundle run.
|
||||
|
||||
The print is a log. It must not fail the build, and it must not invent a
|
||||
command the dispatcher does not send. A commit or channel run also gets a
|
||||
post-build receipt tag. This step runs before that tag exists, so it prints
|
||||
the tag shape and names the step that writes the real tag.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shlex
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
def _text(value: object) -> str:
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _flag(value: object) -> bool:
|
||||
return str(value).lower() == "true"
|
||||
|
||||
|
||||
def bundle_env(raw: object) -> dict[str, str | None]:
|
||||
"""The dispatcher's own bundle-env object, or empty when it is absent."""
|
||||
text = _text(raw).strip()
|
||||
if not text or text == "{}":
|
||||
return {}
|
||||
from scripts.releases.bundle_env import decode
|
||||
|
||||
return decode(text)
|
||||
|
||||
|
||||
def describe(env: Mapping[str, str]) -> dict[str, object]:
|
||||
"""Classify one workflow dispatch without reading git or the network."""
|
||||
tag = _text(env.get("TAG"))
|
||||
commit = _text(env.get("BUILD_COMMIT"))
|
||||
channel = _text(env.get("CHANNEL"))
|
||||
disposable = _text(env.get("DISPOSABLE_CHANNEL"))
|
||||
phase = _text(env.get("RELEASE_PHASE"))
|
||||
if channel or disposable:
|
||||
kind = "channel"
|
||||
elif commit:
|
||||
kind = "commit"
|
||||
elif phase == "candidate":
|
||||
kind = "stable-candidate"
|
||||
elif phase in {"publish", "promote"}:
|
||||
kind = "stable-" + phase
|
||||
elif "-canary." in tag:
|
||||
kind = "canary"
|
||||
elif tag:
|
||||
kind = "stable"
|
||||
else:
|
||||
kind = "unknown"
|
||||
return {
|
||||
"kind": kind,
|
||||
"repository": _text(env.get("GITHUB_REPOSITORY")),
|
||||
"ref": _text(env.get("GITHUB_REF")),
|
||||
"sha": _text(env.get("GITHUB_SHA")),
|
||||
"run_id": _text(env.get("GITHUB_RUN_ID")),
|
||||
"run_attempt": _text(env.get("GITHUB_RUN_ATTEMPT")),
|
||||
"actor": _text(env.get("GITHUB_ACTOR")),
|
||||
"tag": tag,
|
||||
"build_commit": commit,
|
||||
"channel": channel,
|
||||
"disposable_channel": disposable,
|
||||
"release_phase": phase,
|
||||
"upload_release": _flag(env.get("UPLOAD_RELEASE")),
|
||||
"termux_only": _flag(env.get("TERMUX_ONLY")),
|
||||
"termux_upgrade_from_tag": _text(env.get("TERMUX_UPGRADE_FROM_TAG")),
|
||||
"disposable_receivers": _flag(env.get("DISPOSABLE_RECEIVERS")),
|
||||
"disposable_run": _text(env.get("R2_DISPOSABLE_RUN")),
|
||||
"bundle_env": bundle_env(env.get("BUNDLE_ENV_JSON")),
|
||||
}
|
||||
|
||||
|
||||
def release_command(env: Mapping[str, str]) -> list[str] | None:
|
||||
"""The gh workflow command for a dispatch release.py can recreate.
|
||||
|
||||
Stable and canary dispatches mint a tag before they start a workflow, so
|
||||
no release.py command recreates those runs. Return None for them.
|
||||
"""
|
||||
facts = describe(env)
|
||||
repository = facts["repository"]
|
||||
branch = _text(env.get("DEFAULT_BRANCH"))
|
||||
if not repository or not branch or facts["kind"] not in {"commit", "channel"}:
|
||||
return None
|
||||
if facts["disposable_channel"] or facts["disposable_run"] or facts["disposable_receivers"]:
|
||||
return None
|
||||
if facts["termux_only"] or facts["termux_upgrade_from_tag"] or facts["upload_release"]:
|
||||
return None
|
||||
commit = facts["build_commit"]
|
||||
if not isinstance(commit, str) or not commit:
|
||||
return None
|
||||
baked = facts["bundle_env"]
|
||||
if not isinstance(baked, dict):
|
||||
return None
|
||||
if facts["kind"] == "channel":
|
||||
from scripts.releases.channel_build import dispatch_command
|
||||
|
||||
return dispatch_command(str(facts["channel"]), commit, str(repository), branch, baked or None)
|
||||
from scripts.releases.commit_build import dispatch_command
|
||||
|
||||
return dispatch_command(commit, str(repository), branch, baked or None)
|
||||
|
||||
|
||||
def receipt_shape(facts: Mapping[str, object]) -> str | None:
|
||||
"""The post-build receipt tag shape for a commit or channel run.
|
||||
|
||||
The real tag needs the run's creation time, which this step does not have.
|
||||
Show the shape from receipt_tag so the log and the publisher agree.
|
||||
"""
|
||||
kind = facts["kind"]
|
||||
run_id = facts["run_id"]
|
||||
if kind not in {"commit", "channel"} or not isinstance(run_id, str) or not run_id:
|
||||
return None
|
||||
if facts["disposable_run"]:
|
||||
return None
|
||||
from scripts.releases.commit_build import receipt_tag
|
||||
|
||||
return receipt_tag(str(kind), "0.0.0", "2000-01-01T00:00:00Z", run_id).replace(
|
||||
"v0.0.0", "v<version>", 1).replace("20000101T000000Z", "<run-created-utc>", 1)
|
||||
|
||||
|
||||
def report(env: Mapping[str, str]) -> str:
|
||||
"""One log block: the mode, the replay command, the receipt, and the facts."""
|
||||
facts = describe(env)
|
||||
lines = [
|
||||
"::group::Bundle dispatch",
|
||||
"kind: " + str(facts["kind"]),
|
||||
]
|
||||
command = release_command(env)
|
||||
if command is None:
|
||||
lines.append("release.py: this dispatch is not a release.py --build-commit run")
|
||||
else:
|
||||
lines.append("release.py: " + shlex.join(["python", "scripts/release.py", "--publish", "--remote", "<remote>", *command_flags(facts)]))
|
||||
lines.append("workflow: " + shlex.join(command))
|
||||
shape = receipt_shape(facts)
|
||||
if shape is None:
|
||||
lines.append("receipt: this dispatch writes no post-build receipt tag")
|
||||
else:
|
||||
lines.append("receipt: " + shape)
|
||||
lines.append("receipt writer: publish step, after the native jobs succeed")
|
||||
lines.append("facts: " + json.dumps(facts, sort_keys=True))
|
||||
lines.append("::endgroup::")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def command_flags(facts: Mapping[str, object]) -> list[str]:
|
||||
"""The release.py flags that name this dispatch, in dispatcher order."""
|
||||
flags = ["--build-commit", str(facts["build_commit"])]
|
||||
if facts["kind"] == "channel":
|
||||
flags += ["--channel", str(facts["channel"])]
|
||||
baked = facts["bundle_env"]
|
||||
if isinstance(baked, dict):
|
||||
for name in sorted(baked):
|
||||
value = baked[name]
|
||||
if value is None:
|
||||
flags += ["--bundle-unset", name]
|
||||
else:
|
||||
flags += ["--bundle-env", name + "=" + value]
|
||||
return flags
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import os
|
||||
|
||||
print(report(os.environ))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -687,6 +687,8 @@ def put_object(
|
||||
remote_size = match.group(1)
|
||||
if str(remote_size) != str(size):
|
||||
raise R2RequestError("HEAD", key, head.status, f"size mismatch (remote {remote_size}, local {size})")
|
||||
from scripts.releases.upload_summary import note
|
||||
note(key)
|
||||
print(f"OK r2: {key} ({size} bytes)")
|
||||
|
||||
|
||||
|
||||
81
scripts/releases/upload_summary.py
Normal file
81
scripts/releases/upload_summary.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Record each verified R2 upload for the GitHub job summary.
|
||||
|
||||
The record is a log. A missing summary file or a public-URL failure must not
|
||||
fail an upload that already landed. The process exit writes the table, so a
|
||||
step that uploads and then exits still reports its objects.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_KEYS: list[str] = []
|
||||
_REGISTERED = False
|
||||
|
||||
|
||||
def note(key: str) -> None:
|
||||
"""Remember one object key that a verified upload just wrote."""
|
||||
global _REGISTERED
|
||||
if not isinstance(key, str) or not key or key in _KEYS:
|
||||
return
|
||||
_KEYS.append(key)
|
||||
if not _REGISTERED and os.environ.get("GITHUB_STEP_SUMMARY"):
|
||||
atexit.register(flush)
|
||||
_REGISTERED = True
|
||||
|
||||
|
||||
def rows(keys: list[str]) -> list[tuple[str, str]]:
|
||||
"""Public URL for each key, in upload order. A bad key is named, not dropped."""
|
||||
from scripts.releases import r2
|
||||
|
||||
base = r2.public_base_url()
|
||||
listed = []
|
||||
for key in keys:
|
||||
try:
|
||||
listed.append((key, r2.public_url_for(base, key)))
|
||||
except ValueError:
|
||||
listed.append((key, ""))
|
||||
return listed
|
||||
|
||||
|
||||
def render(keys: list[str]) -> str:
|
||||
"""One markdown table. Empty when the step uploaded nothing."""
|
||||
if not keys:
|
||||
return ""
|
||||
lines = ["", "### R2 uploads", "", "| Object | URL |", "|---|---|"]
|
||||
for key, url in rows(keys):
|
||||
cell = f"[download]({url})" if url else "—"
|
||||
lines.append(f"| `{key}` | {cell} |")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def flush() -> None:
|
||||
"""Append the table once. A second call, or a missing summary, writes nothing."""
|
||||
summary = os.environ.get("GITHUB_STEP_SUMMARY", "")
|
||||
if not summary or not _KEYS:
|
||||
return
|
||||
text = render(list(_KEYS))
|
||||
_KEYS.clear()
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
with Path(summary).open("a", encoding="utf-8") as stream:
|
||||
stream.write(text)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Print the table for keys a caller already recorded."""
|
||||
raw = os.environ.get("R2_UPLOAD_KEYS", "")
|
||||
keys = json.loads(raw) if raw else []
|
||||
if not isinstance(keys, list) or not all(isinstance(key, str) for key in keys):
|
||||
raise SystemExit("R2_UPLOAD_KEYS must be a JSON list of object keys")
|
||||
print(render(keys), end="")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -33,7 +33,7 @@ def shell_step(tmp_path, r2_server, job, name, env, *, script=None):
|
||||
'from scripts.releases import r2\n'
|
||||
f'r2.s3_endpoint=lambda _: "http://127.0.0.1:{r2_server.server_port}"\n'
|
||||
'args=sys.argv[1:]\n'
|
||||
'assert args[:2] == ["-m", "scripts.releases.handoff"] or '
|
||||
'assert args[:2] == ["-m", "scripts.releases.handoff"] or args[:2] == ["-m", "scripts.releases.upload_summary"] or '
|
||||
'args[:1] in (["scripts/render-builds-table.py"], ["-"]), args\n'
|
||||
'if args[:1] == ["-m"]:\n'
|
||||
' sys.argv=args[1:]\n'
|
||||
@@ -95,11 +95,10 @@ def test_failed_commit_summary_publishes_downloads_or_run_links(tmp_path, r2_ser
|
||||
assert html.escape(json.dumps(bundle_env['LABEL'], ensure_ascii=False)) in page
|
||||
assert '<script>' not in page and 'must-not-appear' not in page and 'CI_SECRET' not in page
|
||||
links = re.findall(r'\]\((https?://[^)]+)\)', text)
|
||||
download_links = [url for url in links if url.endswith('.msix')]
|
||||
assert run_url in links
|
||||
assert text.count('✅ Built') == page.count('✅ Built') == int(has_download)
|
||||
for url in links:
|
||||
assert f'href="{url}"' in page
|
||||
if url != run_url:
|
||||
for url in download_links:
|
||||
assert url.startswith(base + '/')
|
||||
with urlopen(url, timeout=5) as response:
|
||||
assert response.read() == b'inert downloadable fixture'
|
||||
@@ -187,9 +186,10 @@ def test_commit_staging_and_summary_bind_every_produced_file_without_channels(tm
|
||||
links = re.findall(r'\]\((http[^)]+)\)', text)
|
||||
# Blockmaps are receipt inputs; every other staged product has a download row.
|
||||
expected = {f'{base}/{quote(key, safe="/")}' for key in artifact_keys if not key.endswith('.blockmap')}
|
||||
assert set(links) == expected
|
||||
assert len(links) == len(expected)
|
||||
for url in links:
|
||||
page_url = f'{base}/{quote(f"releases/commit/{sha}/index.html", safe="/")}'
|
||||
assert set(links) == expected | {page_url}
|
||||
assert len(links) == len(expected) + 1
|
||||
for url in expected:
|
||||
with urlopen(url, timeout=5) as response:
|
||||
key = unquote(url.removeprefix(base + '/'))
|
||||
assert response.read() == r2_server.store[key][0]
|
||||
|
||||
119
tests/scripts/test_dispatch_log.py
Normal file
119
tests/scripts/test_dispatch_log.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""The pre-build log must name the release.py command that started the run."""
|
||||
import json
|
||||
import shlex
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.releases import dispatch_log
|
||||
from scripts.releases.bundle_env import parse_assignments
|
||||
from scripts.releases.channel_build import dispatch_command as channel_dispatch
|
||||
from scripts.releases.commit_build import dispatch_command as commit_dispatch
|
||||
from tests.ci.test_desktop_release_tag_admission import _workflow
|
||||
|
||||
SHA = "a" * 40
|
||||
REPOSITORY = "fixture-owner/fixture-repo"
|
||||
BRANCH = "main"
|
||||
|
||||
|
||||
def env(**values):
|
||||
base = {
|
||||
"GITHUB_REPOSITORY": REPOSITORY,
|
||||
"GITHUB_REF": "refs/heads/" + BRANCH,
|
||||
"GITHUB_SHA": SHA,
|
||||
"GITHUB_RUN_ID": "35629258153",
|
||||
"GITHUB_RUN_ATTEMPT": "1",
|
||||
"GITHUB_ACTOR": "ethernet8023",
|
||||
"DEFAULT_BRANCH": BRANCH,
|
||||
"TAG": "",
|
||||
"BUILD_COMMIT": "",
|
||||
"CHANNEL": "",
|
||||
"DISPOSABLE_CHANNEL": "",
|
||||
"DISPOSABLE_RECEIVERS": "false",
|
||||
"RELEASE_PHASE": "",
|
||||
"UPLOAD_RELEASE": "false",
|
||||
"TERMUX_ONLY": "false",
|
||||
"TERMUX_UPGRADE_FROM_TAG": "",
|
||||
"BUNDLE_ENV_JSON": "{}",
|
||||
"R2_DISPOSABLE_RUN": "",
|
||||
}
|
||||
base.update(values)
|
||||
return base
|
||||
|
||||
|
||||
def test_pre_build_setup_prints_the_dispatch_before_any_other_work():
|
||||
steps = _workflow()["jobs"]["validate"]["steps"]
|
||||
printed = steps[1]
|
||||
assert printed["name"] == "Print the release.py command for this run"
|
||||
assert printed["run"] == "python3 -m scripts.releases.dispatch_log"
|
||||
assert "if" not in printed
|
||||
forwarded = {key: value for key, value in printed["env"].items()}
|
||||
assert forwarded["BUILD_COMMIT"] == "${{ inputs.build_commit }}"
|
||||
assert forwarded["CHANNEL"] == "${{ inputs.channel }}"
|
||||
assert forwarded["BUNDLE_ENV_JSON"] == "${{ inputs.bundle_env }}"
|
||||
assert forwarded["DEFAULT_BRANCH"] == "${{ github.event.repository.default_branch }}"
|
||||
assert "GITHUB_TOKEN" not in forwarded and "GH_TOKEN" not in forwarded
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind,extra", [
|
||||
("commit", {}),
|
||||
("channel", {"CHANNEL": "magic-test"}),
|
||||
("commit", {"BUNDLE_ENV_JSON": json.dumps({"HERMES_SKIP_INTRO": "1", "HERMES_HOME": None})}),
|
||||
])
|
||||
def test_printed_command_is_the_dispatcher_command(kind, extra):
|
||||
values = env(BUILD_COMMIT=SHA, **extra)
|
||||
baked = json.loads(values["BUNDLE_ENV_JSON"])
|
||||
if kind == "channel":
|
||||
expected = channel_dispatch(values["CHANNEL"], SHA, REPOSITORY, BRANCH, baked or None)
|
||||
else:
|
||||
expected = commit_dispatch(SHA, REPOSITORY, BRANCH, baked or None)
|
||||
text = dispatch_log.report(values)
|
||||
assert "kind: " + kind in text
|
||||
assert "workflow: " + shlex.join(expected) in text
|
||||
flags = dispatch_log.command_flags(dispatch_log.describe(values))
|
||||
assert "release.py: " + shlex.join(["python", "scripts/release.py", "--publish", "--remote", "<remote>", *flags]) in text
|
||||
assert "receipt: v<version>+" + kind + ".<run-created-utc>.35629258153" in text
|
||||
facts = json.loads(text.split("facts: ", 1)[1].splitlines()[0])
|
||||
assert facts["run_id"] == "35629258153"
|
||||
assert facts["bundle_env"] == baked
|
||||
|
||||
|
||||
@pytest.mark.parametrize("override", [
|
||||
{"TAG": "v0.1.0-canary.20260921120000", "UPLOAD_RELEASE": "true"},
|
||||
{"TAG": "v0.1.0", "RELEASE_PHASE": "candidate"},
|
||||
{"TAG": "v0.1.0", "RELEASE_PHASE": "publish"},
|
||||
{"DISPOSABLE_CHANNEL": "probe", "BUILD_COMMIT": SHA, "R2_DISPOSABLE_RUN": "99"},
|
||||
])
|
||||
def test_unrecreatable_dispatches_name_the_kind_and_no_command(override):
|
||||
text = dispatch_log.report(env(**override))
|
||||
assert "release.py: this dispatch is not a release.py --build-commit run" in text
|
||||
assert "workflow: " not in text
|
||||
assert "receipt: this dispatch writes no post-build receipt tag" in text
|
||||
assert "kind: unknown" not in text
|
||||
|
||||
|
||||
def test_termux_only_commit_has_a_receipt_but_no_release_command():
|
||||
text = dispatch_log.report(env(BUILD_COMMIT=SHA, TERMUX_ONLY="true"))
|
||||
assert "release.py: this dispatch is not a release.py --build-commit run" in text
|
||||
assert "receipt: v<version>+commit.<run-created-utc>.35629258153" in text
|
||||
|
||||
|
||||
def test_report_omits_values_the_workflow_did_not_forward(monkeypatch):
|
||||
monkeypatch.setenv("CLOUDFLARE_R2_SECRET_ACCESS_KEY", "secret-value")
|
||||
text = dispatch_log.report(env(BUILD_COMMIT=SHA))
|
||||
assert "secret-value" not in text
|
||||
|
||||
|
||||
def test_module_prints_the_report_from_the_process_environment(monkeypatch, capsys):
|
||||
for key, value in env(BUILD_COMMIT=SHA, CHANNEL="magic-test").items():
|
||||
monkeypatch.setenv(key, value)
|
||||
dispatch_log.main()
|
||||
text = capsys.readouterr().out
|
||||
assert "kind: channel" in text
|
||||
assert "gh workflow run desktop-bundled-release.yml" in text
|
||||
|
||||
|
||||
def test_bundle_env_round_trips_the_cli_flags():
|
||||
baked = parse_assignments(["HERMES_SKIP_INTRO=1"], ["HERMES_HOME"])
|
||||
facts = dispatch_log.describe(env(BUILD_COMMIT=SHA, BUNDLE_ENV_JSON=json.dumps(baked)))
|
||||
flags = dispatch_log.command_flags(facts)
|
||||
assert flags == ["--build-commit", SHA, "--bundle-unset", "HERMES_HOME", "--bundle-env", "HERMES_SKIP_INTRO=1"]
|
||||
78
tests/scripts/test_upload_summary.py
Normal file
78
tests/scripts/test_upload_summary.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Every verified R2 upload lands in the GitHub step summary, with its public URL."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.releases import r2, upload_summary
|
||||
from scripts.releases.r2_scope import R2Scope
|
||||
|
||||
|
||||
BASE = "https://hermes-assets.nousresearch.com"
|
||||
KEY = "releases/commit/" + "a" * 40 + "/HermesBundled-1.2.3-win-x64.msix"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_notes():
|
||||
upload_summary._KEYS.clear()
|
||||
upload_summary._REGISTERED = False
|
||||
yield
|
||||
upload_summary._KEYS.clear()
|
||||
|
||||
|
||||
def test_render_uses_the_public_asset_host(monkeypatch):
|
||||
monkeypatch.delenv("CLOUDFLARE_R2_PUBLIC_URL", raising=False)
|
||||
monkeypatch.delenv("R2_DISPOSABLE_RUN", raising=False)
|
||||
text = upload_summary.render([KEY])
|
||||
assert f"[download]({BASE}/{KEY})" in text
|
||||
assert text.startswith("\n### R2 uploads\n")
|
||||
assert upload_summary.render([]) == ""
|
||||
|
||||
|
||||
def test_render_names_a_key_whose_url_cannot_be_built(monkeypatch):
|
||||
monkeypatch.setattr(r2, "public_url_for",
|
||||
lambda base, key: (_ for _ in ()).throw(ValueError("bad key")))
|
||||
text = upload_summary.render(["../escape"])
|
||||
assert "`../escape`" in text
|
||||
assert "| — |" in text
|
||||
|
||||
|
||||
def test_note_dedupes_and_flush_appends_once(tmp_path, monkeypatch):
|
||||
summary = tmp_path / "summary.md"
|
||||
summary.write_text("existing\n", encoding="utf-8")
|
||||
monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary))
|
||||
monkeypatch.delenv("CLOUDFLARE_R2_PUBLIC_URL", raising=False)
|
||||
monkeypatch.delenv("R2_DISPOSABLE_RUN", raising=False)
|
||||
upload_summary.note(KEY)
|
||||
upload_summary.note(KEY)
|
||||
upload_summary.flush()
|
||||
upload_summary.flush()
|
||||
text = summary.read_text(encoding="utf-8")
|
||||
assert text.count("### R2 uploads") == 1
|
||||
assert text.startswith("existing\n")
|
||||
assert f"{BASE}/{KEY}" in text
|
||||
|
||||
|
||||
def test_flush_without_a_summary_keeps_the_notes(monkeypatch):
|
||||
monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False)
|
||||
upload_summary.note(KEY)
|
||||
upload_summary.flush()
|
||||
assert upload_summary._KEYS == [KEY]
|
||||
|
||||
|
||||
def test_put_object_records_only_after_the_size_check(monkeypatch):
|
||||
recorded = []
|
||||
monkeypatch.setattr(upload_summary, "note", recorded.append)
|
||||
|
||||
class Response:
|
||||
status = 200
|
||||
def header(self, name):
|
||||
return "11" if name == "content-length" else None
|
||||
def text(self):
|
||||
return ""
|
||||
|
||||
monkeypatch.setattr(r2, "signed_request", lambda *args, **kwargs: Response())
|
||||
monkeypatch.setattr(r2, "R2Scope", type("Scope", (), {"configured": staticmethod(lambda: R2Scope())}))
|
||||
with pytest.raises(r2.R2RequestError):
|
||||
r2.put_object({"access_key_id": "k", "secret_key": "s"}, "https://example.r2.cloudflarestorage.com",
|
||||
"bucket", KEY, b"payload-bytes", "20000101T000000Z", "application/octet-stream", fetcher=None)
|
||||
assert recorded == []
|
||||
Reference in New Issue
Block a user