Files
ethernet dc11e3b3bc feat(release): add --skip-bundles and --skip-tests to stable releases
`release.py release` gains two flags. They can be used together.

--skip-bundles ships only the claim, the GitHub release, the final tag
and the Docker image. No desktop, Termux or PM bundle job runs. The
final tag records candidateManifestSha256: null. Publication moves only
the Docker stable/latest aliases. The R2 stable head, feeds, APT, the
downloads page, the signed-package baseline and the Store stay on the
previous bundle release.

--skip-tests builds, signs and publishes every artifact and runs no
test job: source CI, Nix, PM bundle check, Termux, Windows live,
install/update E2E, bootstrap identity, native smokes, upgrade
acceptance, tests/docker and the in-build vitest step. The candidate
manifest records each smoke as skipped, never as passed.

The flags live in the claim message (skipBundles, skipTests), next to
autopublish. They are not workflow inputs, so a rerun cannot change
them. admit emits them, and every job condition and gate reads them.
stable.validate_claim and stable.validate_final are now the one shape
check for stable.py and the sequencer.

The gates stay strict. SKIPPED_BY in stable.py maps each job to the
flags that remove it. `gate` requires those jobs to report skipped and
every other gated job to report success. A job that ran although a flag
removes it blocks the release.

A release that skipped bundles never moves the R2 stable head. Two
readers depended on that head:

- The next version was derived from it, so the next cut would reuse the
  version. It now takes the newer of the R2 head and the newest
  published non-prerelease GitHub release with a vX.Y.Z tag. Bare v*
  tags do not count, because those refs are not protected yet.
- The sequencer used it to decide which published releases still need
  their publication pass, so a bundle-less release would re-advance
  every 15 minutes. The head is now the newer of the R2 head and the
  published release whose final tag binds the Docker stable alias
  digest.

`release` also refuses a cut when its next version already has a final
tag. That closes the window between the final tag and the public
release, where the published identity still names the old version.

Tests: 42 release test files, 546 passed. Three tests fail on this
Windows host, and they fail the same way on a clean HEAD worktree:

- test_stable_release_graph::test_docker_recovery_refuses_to_replace_a_divergent_version_tag
- test_release_artifacts::test_windows_metadata_is_read_from_package_and_stale_stamp_is_rejected
- test_tag_builds_summary::test_admitted_failure_publishes_tag_info_without_promoting_channel[True]

Not verified: no real Stable Release dispatch ran with either flag, and
actionlint is not installed on this host. The workflow changes are
checked by the graph tests and by running the phase-result step script.
2026-09-24 13:31:33 -04:00

81 lines
3.6 KiB
Python

"""Behavior contract for the `jobs` input parser (no workflow YAML is read)."""
import pytest
from scripts.releases.job_groups import ALL_JOBS, JOB_GROUPS, parse_jobs, phase_jobs, selects_all
def test_the_default_selects_every_group():
for raw in (None, ALL_JOBS):
selected = parse_jobs(raw)
assert set(selected) == set(JOB_GROUPS)
assert all(selected.values())
def test_one_group_selects_only_itself():
selected = parse_jobs("termux")
assert selected["termux"] is True
assert all(value is False for group, value in selected.items() if group != "termux")
selected = parse_jobs("darwin-arm64, win32-bundle")
assert selected == {**{group: False for group in JOB_GROUPS},
"darwin-arm64": True, "win32-bundle": True}
def test_duplicates_unknown_names_and_empty_are_refused():
with pytest.raises(ValueError, match="duplicate"):
parse_jobs("darwin-arm64,darwin-arm64")
with pytest.raises(ValueError, match="unknown job group: mac"):
parse_jobs("mac")
with pytest.raises(ValueError, match="at least one group"):
parse_jobs("")
with pytest.raises(ValueError, match="empty group name"):
parse_jobs("termux,")
with pytest.raises(ValueError, match="unknown job group"):
parse_jobs("termux,smoke-win32")
def test_selects_all_refuses_partial_selections():
assert selects_all(None) is True
assert selects_all(ALL_JOBS) is True
assert selects_all("termux") is False
assert selects_all("darwin-arm64,darwin-x64,win32-arm64,win32-x64") is False
with pytest.raises(ValueError):
selects_all("bogus")
def test_phase_jobs_judge_only_the_selected_groups():
every = {group: True for group in JOB_GROUPS}
candidate = phase_jobs(every, "candidate")
assert candidate == ["validate", "build-darwin-arm64", "smoke-darwin-arm64",
"build-darwin-x64", "smoke-darwin-x64",
"build-win32-arm64", "smoke-win32-arm64",
"build-win32-x64", "smoke-win32-x64",
"assemble-win32-bundle", "termux-deb"]
termux_only = {**{group: False for group in JOB_GROUPS}, "termux": True}
assert phase_jobs(termux_only, "candidate") == ["validate", "termux-deb"]
# B4 moved candidate-manifest into stable-release.yml; the desktop phase
# result judges only the group jobs themselves.
assert "candidate-manifest" not in candidate
partial = {**{group: False for group in JOB_GROUPS}, "darwin-arm64": True}
assert phase_jobs(partial, "candidate") == ["validate", "build-darwin-arm64", "smoke-darwin-arm64"]
# A claim that skipped tests still judges every build, and no smoke.
untested = phase_jobs(every, "candidate", skip_tests=True)
assert untested == [job for job in candidate if not job.startswith("smoke-")]
assert phase_jobs(every, "publish") == ["validate", "stable-publish", "stable-store"]
with pytest.raises(ValueError, match="Unknown release phase"):
phase_jobs(every, "promote")
@pytest.mark.parametrize("jobs, expected", [(None, "true"), ("termux", "false"),
("darwin-arm64,darwin-x64", "false")])
def test_admission_emits_all_jobs_only_for_a_full_selection(monkeypatch, capsys, jobs, expected):
from scripts.releases import job_groups
if jobs is None:
monkeypatch.delenv("JOBS", raising=False)
else:
monkeypatch.setenv("JOBS", jobs)
job_groups.main()
lines = dict(line.split("=", 1) for line in capsys.readouterr().out.splitlines())
assert lines["all-jobs"] == expected
assert set(lines) == {*job_groups.JOB_GROUPS, "all-jobs"}