Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ env:
# same interpreter every pull request's build job already exercised.
PYTHON_VERSION: "3.12"

# A job with no status function in its `if` gets an implicit success(), which is
# false when ANY job upstream of it -- not just its direct needs -- was skipped.
# Each channel always skips a job (`tests` on a tag, promote-browser on a push),
# so every job downstream of one starts its `if` with !cancelled() and checks
# the results it actually depends on.
jobs:
# ---------------------------------------------------------------------------
tests:
Expand Down Expand Up @@ -303,7 +308,9 @@ jobs:
# Built and checked once, before anything is published or promoted; the
# publish jobs upload exactly these files.
needs: plan
if: needs.plan.outputs.publish_library == 'true'
if: >-
!cancelled() && needs.plan.result == 'success' &&
needs.plan.outputs.publish_library == 'true'
runs-on: ubuntu-24.04
permissions:
contents: read
Expand Down Expand Up @@ -395,7 +402,9 @@ jobs:
# After both packages are built and checked, so a broken package never
# leaves a promoted browser behind.
needs: [plan, build-library]
if: needs.plan.outputs.channel == 'stable'
if: >-
!cancelled() && needs.build-library.result == 'success' &&
needs.plan.outputs.channel == 'stable'
runs-on: ubuntu-24.04
permissions:
contents: write # release flags
Expand Down Expand Up @@ -431,6 +440,7 @@ jobs:
publish-npm:
# After PyPI, so npm never has a version PyPI does not.
needs: [plan, publish-pypi]
if: ${{ !cancelled() && needs.publish-pypi.result == 'success' }}
runs-on: ubuntu-24.04
permissions:
id-token: write # trusted publishing and provenance
Expand All @@ -444,14 +454,17 @@ jobs:
with:
name: npm-dist
path: npm-dist
- run: npm publish npm-dist/*.tgz --access public --tag "${{ needs.plan.outputs.dist_tag }}"
# `./` keeps npm from reading `npm-dist/<file>.tgz` as a GitHub owner/repo.
- run: npm publish ./npm-dist/*.tgz --access public --tag "${{ needs.plan.outputs.dist_tag }}"

tag-library:
# Every published library version has a tag; the next merge compares against
# it to decide whether the library changed. A stable version's tag is the one
# that started this run.
needs: [plan, publish-npm]
if: needs.plan.outputs.channel == 'prerelease'
if: >-
!cancelled() && needs.publish-npm.result == 'success' &&
needs.plan.outputs.channel == 'prerelease'
runs-on: ubuntu-24.04
permissions:
contents: write
Expand Down
56 changes: 56 additions & 0 deletions ci/tests/test_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import shutil

import pytest
import yaml

from ci import browser_inputs, release
from ci._util import read_upstream_sh
Expand Down Expand Up @@ -208,3 +209,58 @@ def test_the_pin_and_the_launchers_agree_on_its_format(tmp_path, monkeypatch):
pin = load_pin(release.PIN_FILE)
assert (pin.repo_name, pin.version, pin.build, pin.tag) == \
("official", "156.0.1", "beta.33", "v156.0.1-beta.33")


# The jobs each channel skips by design, and the jobs it must reach.
CHANNELS = {
"prerelease": ({"promote-browser"},
{"build-browser", "publish-browser", "build-library", "publish-pypi",
"publish-npm", "tag-library"}),
"stable": ({"tests", "build-browser", "publish-browser", "tag-library"},
{"plan", "build-library", "promote-browser", "publish-pypi", "publish-npm"}),
}


@pytest.mark.parametrize("channel", CHANNELS)
def test_no_job_a_channel_needs_is_skipped_by_a_job_it_skips(channel):
"""A job whose `if` has no status function gets an implicit success(), which
is false when any job upstream of it -- not only its direct needs -- was
skipped. The first prerelease published to PyPI and skipped npm that way:
promote-browser, skipped on every push, sits upstream of publish-npm."""
jobs = yaml.safe_load((release.REPO_ROOT / ".github/workflows/release.yml")
.read_text(encoding="utf-8"))["jobs"]

def needs(name):
n = jobs[name].get("needs", [])
return [n] if isinstance(n, str) else n

def upstream(name):
seen = set()
stack = list(needs(name))
while stack:
job = stack.pop()
if job not in seen:
seen.add(job)
stack.extend(needs(job))
return seen

skipped, required = CHANNELS[channel]
unguarded = [
name for name in sorted(required)
if upstream(name) & skipped
and not re.search(r"!cancelled\(\)|always\(\)", str(jobs[name].get("if", "")))
]
assert not unguarded, f"{channel}: skipped by an upstream job it does not need: {unguarded}"


def test_npm_publish_is_given_a_local_path():
"""npm reads `dir/file.tgz` as a GitHub owner/repo and tries to clone it; the
first npm publish failed that way. A local tarball needs `./` or `/`."""
jobs = yaml.safe_load((release.REPO_ROOT / ".github/workflows/release.yml")
.read_text(encoding="utf-8"))["jobs"]
runs = [step["run"] for job in jobs.values() for step in job.get("steps", [])
if "npm publish" in step.get("run", "")]
assert runs
for run in runs:
target = re.search(r"npm publish\s+(\S+)", run).group(1)
assert target.startswith(("./", "/")), f"not a local path to npm: {target}"
Loading