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
7 changes: 7 additions & 0 deletions .github/workflows/packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ on:
required: true
type: boolean
default: false
exclude_ids:
description: Optional comma-separated package IDs skipped by a publish_nuget dispatch on a stable 3.10 tag. Empty means none. Rejected on any other path.
required: false
type: string
default: ''
push:
branches:
- 'main'
Expand Down Expand Up @@ -63,6 +68,7 @@ jobs:
PUBLISH_NUGET: ${{ inputs.publish_nuget }}
PUBLISH_NPM: ${{ inputs.publish_npm }}
DEPLOY_COVERAGE: ${{ inputs.deploy_coverage }}
EXCLUDE_IDS: ${{ inputs.exclude_ids }}
run: python3 scripts/validate_packages_publication_selection.py

test_unit_integration:
Expand Down Expand Up @@ -506,6 +512,7 @@ jobs:
REF: ${{ github.ref }}
CANDIDATE_ARCHIVE: ${{ runner.temp }}/candidate.zip
PACKAGES_DIR: ${{ github.workspace }}
EXCLUDE_IDS: ${{ inputs.exclude_ids }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
elsa-workflows-review-bot[bot] marked this conversation as resolved.
Resolved
Comment thread
elsa-workflows-review-bot[bot] marked this conversation as resolved.
Resolved
run: |
python3 scripts/stage_consolidated_candidate_nupkgs.py stage
python3 scripts/validate_packages_id_set.py
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
The Packages workflow now builds and tests normally on pushes and release events, but those events cannot publish packages or deploy coverage. The four publication jobs are default-off `workflow_dispatch` options:

- `publish_preview_feedz` publishes the run's packages to Feedz. Dispatch from a branch for a preview build (`<base_version>-preview.<run number>`), or from a 3.10.x prerelease tag. Stable 3.10.x Feedz is owned only by `publish-consolidated-original-candidate.yml`.
- `publish_nuget` publishes a release-tag package to NuGet.org. Dispatch from a release-version tag. A stable 3.10.x nuget.org dispatch downloads the current pin (run `40347931480`, artifact `12664519771`, source `5cff67228e550b353b3483eb3b37dfdb73a41c74`), verifies each `.nupkg` sha256 against the candidate seal, and runs the 231-ID list and Studio content guards on that exact set before any push. Other tags push this run's pack.
- `publish_nuget` publishes a release-tag package to NuGet.org. Dispatch from a release-version tag. A stable 3.10.x nuget.org dispatch downloads the current pin (run `40347931480`, artifact `12664519771`, source `5cff67228e550b353b3483eb3b37dfdb73a41c74`), verifies each `.nupkg` sha256 against the candidate seal, and runs the 231-ID list and Studio content guards on that exact set before any push. Other tags push this run's pack. An optional `exclude_ids` dispatch input lists package IDs to skip on a `publish_nuget` dispatch for a stable 3.10 tag; empty keeps the full set. A non-empty list is rejected on any other path. Remaining packages must not depend on an excluded ID.
- `publish_npm` publishes `@elsa-workflows/elsa-studio-wasm` and `@elsa-workflows/elsa-studio-wasm-react` to Feedz. `build_npm` builds the pair without credentials. Dispatch from a release-version tag. Feedz npm is live for 3.10.x prerelease tags. The npmjs.com upload is not in this job until trusted publishing is confirmed for `elsa-workflows/elsa-core` (#8725). Stable 3.10.x is rejected in the selection check and also fail-closed in the job. The pair builder accepts 3.8–3.10 versions only, so a 3.11+ tag fails that check.
- `deploy_coverage` deploys the coverage report to GitHub Pages. Dispatch from `main` or `release/3.6.1`, the refs whose coverage run uploads a Pages artifact.

Expand Down
163 changes: 163 additions & 0 deletions scripts/test_validate_packages_id_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,15 @@
DEFAULT_EXPECTED,
STABLE_310_PATTERN,
STUDIO_CONTENT,
apply_exclude_ids,
exclusion_dependency_errors,
filename_package_id,
id_set_errors,
is_stable_310,
load_expected_ids,
packed_ids,
parse_exclude_ids,
resolve_exclude_ids,
studio_content_errors,
)

Expand Down Expand Up @@ -112,6 +117,7 @@ def test_script_accepts_the_expected_set_and_rejects_mutations(self):
text=True,
)
self.assertEqual(0, accepted.returncode, accepted.stderr)
self.assertIn("exclusions: none", accepted.stdout)
self.assertIn("231 expected IDs", accepted.stdout)
self.assertIn("Studio package content matches 8 required entries.", accepted.stdout)

Expand Down Expand Up @@ -178,6 +184,158 @@ def test_script_has_no_skip_bypass_or_override(self):
self.assertNotIn(marker, lowered)


class ExcludeIdsTests(unittest.TestCase):
def setUp(self):
self.expected = load_expected_ids(DEFAULT_EXPECTED)
self.assertEqual(231, len(self.expected))

def write_packages(self, directory, ids, version="3.10.0", dependencies=None):
dependencies = dependencies or {}
for package_id in ids:
path = directory / f"{package_id}.{version}.nupkg"
dep_xml = "".join(
f'<dependency id="{dep}" version="{version}" />'
for dep in dependencies.get(package_id, ())
)
nuspec = (
'<?xml version="1.0"?>'
'<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">'
"<metadata>"
f"<id>{package_id}</id>"
f"<version>{version}</version>"
"<authors>Elsa</authors>"
"<description>test</description>"
f"<dependencies>{dep_xml}</dependencies>"
"</metadata>"
"</package>"
)
with zipfile.ZipFile(path, "w") as archive:
archive.writestr(f"{package_id}.nuspec", nuspec)
for entry in STUDIO_CONTENT.get(package_id, ()):
archive.writestr(entry, b"bundle")
(directory / f"{package_id}.{version}.snupkg").write_bytes(b"snupkg")

def test_empty_exclude_ids_leaves_the_full_set(self):
for raw in (None, "", " "):
with self.subTest(raw=raw):
self.assertEqual([], parse_exclude_ids(raw))
self.assertEqual([], resolve_exclude_ids(raw, self.expected))
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
self.write_packages(directory, self.expected)
remaining, excluded = apply_exclude_ids(directory, list(self.expected), "")
self.assertEqual([], excluded)
self.assertEqual(self.expected, remaining)
self.assertEqual(231, len(packed_ids(directory)))
self.assertEqual(self.expected, packed_ids(directory))

def test_valid_exclusion_adjusts_the_expected_count(self):
skipped = self.expected[0]
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
self.write_packages(directory, self.expected)
remaining, excluded = apply_exclude_ids(directory, list(self.expected), f" {skipped.lower()} ")
self.assertEqual([skipped], excluded)
self.assertEqual(230, len(remaining))
self.assertNotIn(skipped, remaining)
self.assertEqual(230, len(packed_ids(directory)))
self.assertNotIn(skipped, packed_ids(directory))
self.assertFalse(any(filename_package_id(path.name) == skipped for path in directory.iterdir() if path.is_file()))
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
self.write_packages(directory, self.expected)
accepted = subprocess.run(
[sys.executable, str(SCRIPT)],
env={**os.environ, "PACKAGES_DIR": str(directory), "EXCLUDE_IDS": skipped},
capture_output=True,
text=True,
)
self.assertEqual(0, accepted.returncode, accepted.stderr)
self.assertIn(f"exclusions: {skipped}", accepted.stdout)
self.assertIn("230 expected IDs", accepted.stdout)
self.assertNotIn(skipped, packed_ids(directory))

def test_unknown_id_fails(self):
with self.assertRaises(SystemExit) as raised:
resolve_exclude_ids("NotAPackageId", self.expected)
self.assertIn("not in the expected package set", str(raised.exception))
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
self.write_packages(directory, self.expected)
rejected = subprocess.run(
[sys.executable, str(SCRIPT)],
env={**os.environ, "PACKAGES_DIR": str(directory), "EXCLUDE_IDS": "NotAPackageId"},
capture_output=True,
text=True,
)
self.assertEqual(1, rejected.returncode)
self.assertIn("not in the expected package set", rejected.stderr)
self.assertEqual(231, len(packed_ids(directory)))

def test_duplicate_id_fails(self):
package_id = self.expected[0]
for raw in (f"{package_id},{package_id}", f"{package_id}, {package_id.lower()}"):
with self.subTest(raw=raw), self.assertRaises(SystemExit) as raised:
parse_exclude_ids(raw)
self.assertIn("more than once", str(raised.exception))
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
self.write_packages(directory, self.expected)
rejected = subprocess.run(
[sys.executable, str(SCRIPT)],
env={**os.environ, "PACKAGES_DIR": str(directory), "EXCLUDE_IDS": f"{package_id},{package_id}"},
capture_output=True,
text=True,
)
self.assertEqual(1, rejected.returncode)
self.assertIn("more than once", rejected.stderr)
self.assertEqual(231, len(packed_ids(directory)))

def test_empty_entry_fails(self):
package_id = self.expected[0]
for raw in (f"{package_id},", f",{package_id}", f"{package_id},,{self.expected[1]}"):
with self.subTest(raw=raw), self.assertRaises(SystemExit) as raised:
parse_exclude_ids(raw)
self.assertIn("empty entry", str(raised.exception))

def test_exclusion_fails_when_a_remaining_package_depends_on_it(self):
skipped = self.expected[0]
dependent = self.expected[1]
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
self.write_packages(directory, self.expected, dependencies={dependent: (skipped,)})
errors = exclusion_dependency_errors(directory, {skipped})
self.assertTrue(errors)
self.assertIn("Remaining packages depend on an excluded ID:", errors)
self.assertIn(f"{dependent} depends on {skipped}", errors)
rejected = subprocess.run(
[sys.executable, str(SCRIPT)],
env={**os.environ, "PACKAGES_DIR": str(directory), "EXCLUDE_IDS": skipped},
capture_output=True,
text=True,
)
self.assertEqual(1, rejected.returncode)
self.assertIn("Remaining packages depend on an excluded ID:", rejected.stderr)
self.assertIn(f"{dependent} depends on {skipped}", rejected.stderr)
self.assertNotIn(skipped, packed_ids(directory))

def test_exclusion_dependency_match_is_case_insensitive(self):
skipped = self.expected[0]
dependent = self.expected[1]
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
self.write_packages(directory, self.expected, dependencies={dependent: (skipped.lower(),)})
errors = exclusion_dependency_errors(directory, {skipped})
self.assertIn(f"{dependent} depends on {skipped}", errors)

def test_exclusion_cannot_clear_an_unexpected_packed_id(self):
stray = "Elsa.SamplePackage"
self.assertNotIn(stray, self.expected)
with self.assertRaises(SystemExit) as raised:
resolve_exclude_ids(stray, self.expected)
self.assertIn("not in the expected package set", str(raised.exception))


class StudioContentTests(unittest.TestCase):
def test_required_assets_match_clientlib_assets(self):
sys.path.insert(0, str(Path(__file__).resolve().parent / "integration-program"))
Expand Down Expand Up @@ -331,6 +489,11 @@ def test_packages_workflow_promotes_stable_310_instead_of_packing(self):
self.assertIn("python3 scripts/stage_consolidated_candidate_nupkgs.py retrieve", source)
self.assertIn("python3 scripts/stage_consolidated_candidate_nupkgs.py stage", source)
self.assertIn("python3 scripts/validate_packages_id_set.py", source)
self.assertIn("exclude_ids:", source)
self.assertIn("EXCLUDE_IDS: ${{ inputs.exclude_ids }}", source)
candidate = Path(__file__).resolve().parents[1] / ".github/workflows/publish-consolidated-original-candidate.yml"
self.assertNotIn("exclude_ids", candidate.read_text(encoding="utf-8"))
self.assertNotIn("EXCLUDE_IDS", candidate.read_text(encoding="utf-8"))
compile_if = (
"if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.publish_nuget"
" && startsWith(github.ref, 'refs/tags/3.10.') && !contains(github.ref_name, '-')) }}"
Expand Down
32 changes: 30 additions & 2 deletions scripts/test_validate_packages_publication_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,14 @@
)


def errors(ref, selected):
def errors(ref, selected, exclude_ids=None):
return selection_errors(
ref,
feedz="feedz" in selected,
nuget="nuget" in selected,
npm="npm" in selected,
coverage="coverage" in selected,
exclude_ids=exclude_ids,
)


Expand All @@ -94,6 +95,19 @@ def test_candidate_optins_and_artifact_selectors_have_no_publisher_route(self):
self.assertEqual([], errors("refs/tags/3.10.0-rc1", {"feedz"}))
self.assertEqual([], errors("refs/tags/3.8.5", {"nuget"}))
self.assertEqual([], errors("refs/tags/3.9.1", {"nuget"}))
self.assertEqual([], errors("refs/tags/3.10.0", {"nuget"}, exclude_ids="Elsa"))
self.assertIn(
"exclude_ids is only accepted",
" ".join(errors("refs/tags/3.10.0-rc1", {"nuget"}, exclude_ids="Elsa")),
)
self.assertIn(
"exclude_ids is only accepted",
" ".join(errors("refs/tags/3.8.5", {"nuget"}, exclude_ids="Elsa")),
)
self.assertIn(
"exclude_ids is only accepted",
" ".join(errors("refs/tags/3.10.0", set(), exclude_ids="Elsa")),
)

def test_rejects_mismatched_selections_for_the_stated_reason(self):
for ref, selected, reason in REJECTED:
Expand All @@ -104,14 +118,15 @@ def test_rejects_mismatched_selections_for_the_stated_reason(self):
class SelectionScriptTests(unittest.TestCase):
"""The job runs the script with the ref and inputs in its environment."""

def run_script(self, ref, feedz="false", nuget="false", npm="false", coverage="false"):
def run_script(self, ref, feedz="false", nuget="false", npm="false", coverage="false", exclude_ids=""):
env = {
**os.environ,
"REF": ref,
"PUBLISH_PREVIEW_FEEDZ": feedz,
"PUBLISH_NUGET": nuget,
"PUBLISH_NPM": npm,
"DEPLOY_COVERAGE": coverage,
"EXCLUDE_IDS": exclude_ids,
}
return subprocess.run([sys.executable, str(SCRIPT)], env=env, capture_output=True, text=True)

Expand All @@ -137,6 +152,19 @@ def test_input_that_is_not_a_boolean_fails_the_step(self):
self.assertNotEqual(0, result.returncode)
self.assertIn("PUBLISH_NUGET must be 'true' or 'false'", result.stderr)

def test_exclude_ids_is_rejected_except_on_stable_310_nuget(self):
accepted = self.run_script("refs/tags/3.10.0", nuget="true", exclude_ids="Elsa")
self.assertEqual(0, accepted.returncode, accepted.stderr)
rejected = self.run_script("refs/tags/3.10.0-rc1", nuget="true", exclude_ids="Elsa")
self.assertEqual(1, rejected.returncode)
self.assertIn("exclude_ids is only accepted", rejected.stderr)
rejected = self.run_script("refs/tags/3.8.5", nuget="true", exclude_ids="Elsa")
self.assertEqual(1, rejected.returncode)
self.assertIn("exclude_ids is only accepted", rejected.stderr)
rejected = self.run_script("refs/tags/3.10.0", nuget="false", exclude_ids="Elsa")
self.assertEqual(1, rejected.returncode)
self.assertIn("exclude_ids is only accepted", rejected.stderr)


if __name__ == "__main__":
unittest.main()
32 changes: 30 additions & 2 deletions scripts/test_validate_packages_workflow_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@
),
(
"selection script sees a fixed ref",
" REF: ${{ github.ref }}\n PUBLISH_PREVIEW_FEEDZ: ${{ inputs.publish_preview_feedz }}\n PUBLISH_NUGET: ${{ inputs.publish_nuget }}\n PUBLISH_NPM: ${{ inputs.publish_npm }}",
" REF: refs/heads/main\n PUBLISH_PREVIEW_FEEDZ: ${{ inputs.publish_preview_feedz }}\n PUBLISH_NUGET: ${{ inputs.publish_nuget }}\n PUBLISH_NPM: ${{ inputs.publish_npm }}",
" REF: ${{ github.ref }}\n PUBLISH_PREVIEW_FEEDZ: ${{ inputs.publish_preview_feedz }}\n PUBLISH_NUGET: ${{ inputs.publish_nuget }}\n PUBLISH_NPM: ${{ inputs.publish_npm }}\n DEPLOY_COVERAGE: ${{ inputs.deploy_coverage }}\n EXCLUDE_IDS: ${{ inputs.exclude_ids }}",
" REF: refs/heads/main\n PUBLISH_PREVIEW_FEEDZ: ${{ inputs.publish_preview_feedz }}\n PUBLISH_NUGET: ${{ inputs.publish_nuget }}\n PUBLISH_NPM: ${{ inputs.publish_npm }}\n DEPLOY_COVERAGE: ${{ inputs.deploy_coverage }}\n EXCLUDE_IDS: ${{ inputs.exclude_ids }}",
"validate_publication_selection must pass REF: ${{ github.ref }}",
),
(
Expand All @@ -145,6 +145,24 @@
" - uses: actions/setup-python@v5\n if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.publish_nuget && startsWith(github.ref, 'refs/tags/3.10.') && !contains(github.ref_name, '-')) }}\n with:\n python-version: '3.12'\n",
"build job must contain setup-dotnet",
),
(
"nuget guard hard-codes EXCLUDE_IDS",
" PACKAGES_DIR: ${{ github.workspace }}\n EXCLUDE_IDS: ${{ inputs.exclude_ids }}\n",
" PACKAGES_DIR: ${{ github.workspace }}\n EXCLUDE_IDS: Elsa.Studio.Workflows.Designer\n",
"publish_nuget candidate guard env EXCLUDE_IDS must be ${{ inputs.exclude_ids }}",
),
(
"nuget publisher sets EXCLUDE_IDS at job level",
" if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_nuget && startsWith(github.ref, 'refs/tags/') }}\n permissions:\n",
" if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_nuget && startsWith(github.ref, 'refs/tags/') }}\n env:\n EXCLUDE_IDS: Elsa.Studio.Workflows.Designer\n permissions:\n",
"publish_nuget must not set EXCLUDE_IDS at job level",
),
(
"packages.yml sets EXCLUDE_IDS at workflow level",
" base_version: '3.10.0'\n",
" base_version: '3.10.0'\n EXCLUDE_IDS: Elsa.Studio.Workflows.Designer\n",
"packages.yml must not set EXCLUDE_IDS at workflow level",
),
(
"workflow is callable by another workflow",
"on:\n workflow_dispatch:\n",
Expand Down Expand Up @@ -531,6 +549,16 @@ def test_candidate_guard_rejects_a_different_ref(self):
gates.candidate_publisher_violations(mutated),
)

def test_candidate_publisher_rejects_exclude_ids(self):
source = gates.CANDIDATE_WORKFLOW.read_text(encoding="utf-8")
old = " PACKAGES_DIR: ${{ runner.temp }}/candidate-nupkgs\n"
self.assertEqual(1, source.count(old))
mutated = source.replace(old, old + " EXCLUDE_IDS: Elsa\n")
self.assertIn(
"publish-consolidated-original-candidate.yml must not set EXCLUDE_IDS",
gates.candidate_publisher_violations(mutated),
)

NUGET_GUARD_RUN_BLOCK = (
" run: |\n"
" python3 scripts/stage_consolidated_candidate_nupkgs.py stage\n"
Expand Down
Loading
Loading