diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ed04670c..38ff7ac87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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 @@ -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 @@ -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 @@ -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/.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 diff --git a/ci/tests/test_release.py b/ci/tests/test_release.py index 5d0b25868..e319d7e1e 100644 --- a/ci/tests/test_release.py +++ b/ci/tests/test_release.py @@ -6,6 +6,7 @@ import shutil import pytest +import yaml from ci import browser_inputs, release from ci._util import read_upstream_sh @@ -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}"