Skip to content

Support custom build workflows for platform-specific packages - #6891

Open
masenf wants to merge 4 commits into
mainfrom
claude/reflex-release-artifact-custom-nn2vog
Open

Support custom build workflows for platform-specific packages#6891
masenf wants to merge 4 commits into
mainfrom
claude/reflex-release-artifact-custom-nn2vog

Conversation

@masenf

@masenf masenf commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Type of change

  • New feature (non-breaking change which adds functionality)
  • This change requires a documentation update

Description

This PR adds support for delegating a package's build to a repository-owned workflow, enabling packages with platform-specific artifacts (e.g., compiled extensions requiring a matrix of OS-specific builds) to participate in the reflex-release pipeline.

Key changes

Configuration (config.py):

  • Added CustomBuild dataclass to represent a custom build workflow configuration with packages, workflow filename, and optional artifact expectations
  • Added custom-build configuration table support with validation
  • Added helper methods: custom_build_for(), custom_build_packages(), expect_artifacts()
  • Validation ensures: packages are known, workflows are bare filenames, no package is listed twice, no two entries share a job ID, and custom builds are incompatible with exact-pin lockstep members

Workflow generation (scaffold.py):

  • Split the monolithic build job into three stages:
    • prepare: Validates the release request and emits version/tag/build-dir outputs
    • build: Runs uv build for packages without custom workflows
    • collect: Gathers artifacts from all build paths, verifies them, and prepares for approval
  • Added custom build job generation that calls repository-owned workflows with standardized inputs (package, version, tag, build-dir, artifact-prefix)
  • Custom build jobs run unprivileged (contents: read only) before the approval gate, maintaining the security boundary
  • Added dev-pin gate for custom-built packages in the prepare job (they skip the build job where it normally runs)
  • Updated publish.yml template with placeholders for custom build integration

Artifact verification (dist.py):

  • Extended verify_dist() to accept optional expect patterns that must each match at least one built file
  • Ensures matrix builds are complete—a leg that silently produced nothing fails the release rather than shipping an incomplete set

Testing (test_scaffold.py, test_config.py, test_dist.py):

  • Added comprehensive tests for custom build configuration parsing and validation
  • Added tests for workflow generation with custom builds (job ordering, permissions, inputs, artifact collection)
  • Added tests for artifact expectation validation
  • Added helper write_custom_build() for test setup

Security model

Custom build workflows inherit the calling job's contents: read permission and cannot request additional privileges. They run before the approval gate alongside the built-in build job, so:

  • Repository code (build backend, hooks, custom workflows) executes only where there is nothing to steal
  • The publish job holding the PyPI credential runs no repository code
  • A malicious build can corrupt artifacts, but reviewers approving the release see the verified artifacts before approving the token

Documentation

Updated README.md with:

  • New "Custom builds" section explaining the use case and contract
  • Updated security model explanation to cover custom workflows
  • Updated approval gate documentation to reference the collect job's artifact verification
  • Updated workflow stage table with new prepare and collect stages

Test Plan

  • All existing tests pass (workflow generation, config parsing, artifact verification)
  • New unit tests cover: custom build configuration validation, workflow generation with custom builds, artifact expectation matching, and error cases (missing workflows, missing workflow_call trigger, invalid configurations)
  • Existing integration tests continue to pass with no custom builds configured

https://claude.ai/code/session_01E1ujR3svpGxMwm5pBMrsXh

Review in cubic

Packages whose artifacts cannot come from a single `uv build` — a matrix of
platform-specific wheels, say — can now hand their build to a workflow the
consuming repository owns:

    [[tool.reflex-release.custom-build]]
    packages = ["mypkg"]
    workflow = "build_wheels.yml"
    expect-artifacts = ["*.tar.gz", "*-macosx_*_arm64.whl"]

The generated publish.yml calls that workflow in place of its own build job
for those packages, passing the package, version, tag, build directory and
the artifact-name prefix to upload under.

publish.yml is restructured from build/publish/tag-and-release into
prepare/build/collect/publish/tag-and-release so that the artifact
verification, the post-build hook, the release notes and the checksum
manifest run in one place whichever job produced the files. The custom build
jobs sit between prepare and collect, so the whole matrix runs before the
approval gate, and collect tolerates a skipped build path but never a failed
one — a lost matrix leg stops the release instead of uploading a partial set.

The trust boundary is unchanged: the calling job grants only contents: read
and no secrets, and a called workflow cannot hold more privilege than its
caller grants, so a custom build is inside the same unprivileged boundary as
the built-in one. verify-dist checks every collected file is that package at
that version, and expect-artifacts additionally requires the set to be
complete, since a version can only be uploaded to PyPI once.

Also:
- reject custom-build on a pin-exact lockstep member, whose pyproject.toml
  rewrite happens in a checkout the custom workflow never sees;
- run the dev-pin gate in prepare for custom-built packages, which never
  reach the build job where it normally runs;
- fail `sync` (so `sync --check` on every PR) when a configured build
  workflow is missing or declares no workflow_call trigger;
- pass DIST_DIR to post_build.sh and fix the README example, which used a
  path the build never wrote to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1ujR3svpGxMwm5pBMrsXh
@masenf
masenf requested a review from a team as a code owner August 14, 2026 21:51
Towncrier fragments are named <pr-number>.<type>.md, and the issue_format
turns that number into the changelog's link. Renamed from a placeholder to
the actual pull request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1ujR3svpGxMwm5pBMrsXh
@codspeed-hq

codspeed-hq Bot commented Aug 14, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing claude/reflex-release-artifact-custom-nn2vog (3bb2f39) with main (092aa4f)

Open in CodSpeed

Footnotes

  1. 8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@greptile-apps

greptile-apps Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds repository-owned custom build workflows for packages requiring platform-specific artifacts while retaining the existing approval and publishing boundary.

  • Adds validated custom-build configuration and reusable-workflow contracts.
  • Splits publishing into preparation, build, artifact collection, approval, and publication stages.
  • Adds package-specific artifact expectations and supporting tests and documentation.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains within the eligible follow-up scope.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/reflex-release/src/reflex_release/config.py Adds the custom-build configuration model, package/workflow validation, and artifact expectation lookup.
packages/reflex-release/src/reflex_release/scaffold.py Generates custom reusable-workflow jobs and validates their declared workflow-call interface during synchronization.
packages/reflex-release/src/reflex_release/templates/workflows/publish.yml Splits the release workflow into prepare, build, collect, publish, and release stages and integrates custom build artifacts.
packages/reflex-release/src/reflex_release/dist.py Extends distribution verification to require configured filename patterns.
tests/units/reflex_release/test_scaffold.py Adds coverage for generated custom-build jobs, workflow contracts, artifact collection, and stage dependencies.

Reviews (3): Last reviewed commit: "Merge main into the custom build branch" | Re-trigger Greptile

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found and verified against the latest diff

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/reflex-release/src/reflex_release/scaffold.py">

<violation number="1" location="packages/reflex-release/src/reflex_release/scaffold.py:531">
P1: When a custom workflow declares `workflow_call` without the five contract inputs, `sync` accepts it but GitHub rejects the generated call at release time. Validate `workflow_call.inputs` and their required string types before accepting the workflow.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

f"does not exist. Create it with:\n\n{CUSTOM_BUILD_CONTRACT}"
)
if not re.search(
r"^\s*workflow_call:", target.read_text(encoding="utf-8"), re.MULTILINE

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a custom workflow declares workflow_call without the five contract inputs, sync accepts it but GitHub rejects the generated call at release time. Validate workflow_call.inputs and their required string types before accepting the workflow.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/reflex-release/src/reflex_release/scaffold.py, line 531:

<comment>When a custom workflow declares `workflow_call` without the five contract inputs, `sync` accepts it but GitHub rejects the generated call at release time. Validate `workflow_call.inputs` and their required string types before accepting the workflow.</comment>

<file context>
@@ -343,6 +502,41 @@ def check_title_format(config: Config) -> None:
+                f"does not exist. Create it with:\n\n{CUSTOM_BUILD_CONTRACT}"
+            )
+        if not re.search(
+            r"^\s*workflow_call:", target.read_text(encoding="utf-8"), re.MULTILINE
+        ):
+            fail(
</file context>

Comment thread packages/reflex-release/src/reflex_release/config.py Outdated
Comment thread packages/reflex-release/src/reflex_release/config.py Outdated
masenf added a commit to reflex-dev/xy that referenced this pull request Aug 14, 2026
Pin reflex-release at 945a87bd, which adds custom build workflows
(reflex-dev/reflex#6891), and use it to end the publish.yml fork.

xy's matrix moves to .github/workflows/build_release_artifacts.yml, a workflow
this repository owns and the tool never touches, wired up by

    [[tool.reflex-release.custom-build]]
    packages = ["xy"]
    workflow = "build_release_artifacts.yml"

publish.yml is now the tool's output, verbatim — as are the other three — so
`reflex-release sync` regenerates all four and `sync --check`, which the
generated changelog.yml runs on every pull request, is finally green. Upgrading
the tool is a pin bump plus one command, with no patch to re-apply.

expect-artifacts names all thirteen files a release must contain. A matrix leg
that runs, succeeds and uploads nothing looks exactly like one never configured,
and PyPI accepts a version once, so an incomplete set now stops the release
before the approval instead of shipping a version some users cannot install.

Two xy behaviors that lived in the forked publish.yml move into the build
workflow, which is where they belong:

- the tag-shape gate, as a `version-gate` job the three build jobs depend on, so
  it still runs before half an hour of cross-compilation;
- the dry run, as that workflow's own workflow_dispatch. It has no publish path
  at all — the upload lives in publish.yml, which a dry run never calls — which
  is a stronger guarantee than the boolean gate it replaces.

The action pins are the other half of taking the four verbatim: normalizing them
would put every generated workflow permanently out of sync with the tool, so
xy's one-SHA-per-action policy now applies to the workflows xy authors,
build_release_artifacts.yml included. A test asserts the marker that scopes it
still identifies exactly the four generated files.

One regression, documented rather than hidden: deploy-docs-stg.yml no longer
starts automatically after a release. Its `push: tags` trigger cannot see a tag
pushed with GITHUB_TOKEN, and a generated publish.yml cannot dispatch it, so it
is a manual run with the released tag until the pinned commit carries
reflex-release's post-release hook.
- Validate that a custom build workflow declares all five contract inputs,
  not just a workflow_call trigger. GitHub rejects a call naming an
  undeclared input, so a renamed or forgotten input used to surface as a
  failed release; `sync --check` now makes it a red pull request. Read by
  indentation rather than with a YAML parser, which this tool deliberately
  does not carry on the release path — it is lenient, and what it misses
  GitHub still rejects before any job runs.
- Restrict the workflow filename to a YAML-safe bare filename. It is
  interpolated into the generated `uses:` as a bare scalar, so a name
  carrying YAML structure produced altered workflow YAML instead of an error.
- Give collect's checkout full history and tags. post_build.sh moved there
  from the build job, whose checkout has both, and a hook that inspects them
  has to keep working. Documented the one remaining difference: collect
  builds nothing, so the release tag is not applied locally.
- Name the sub-table in type errors from [[lockstep]] and [[custom-build]]
  entries, which pointed at [tool.reflex-release] instead of the table the
  key actually lives in. Fixed in the shared helper, so the pre-existing
  lockstep case is covered too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1ujR3svpGxMwm5pBMrsXh

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 7 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/reflex-release/src/reflex_release/scaffold.py">

<violation number="1" location="packages/reflex-release/src/reflex_release/scaffold.py:552">
P2: When a custom workflow adds an inline YAML comment after `workflow_call:` or `inputs:`, `sync` rejects the valid workflow as missing every required input. Allow an optional inline comment when matching block keys.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Returns:
The index, or None when no line declares that key with a nested block.
"""
pattern = re.compile(rf"\s*{re.escape(key)}:$")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a custom workflow adds an inline YAML comment after workflow_call: or inputs:, sync rejects the valid workflow as missing every required input. Allow an optional inline comment when matching block keys.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/reflex-release/src/reflex_release/scaffold.py, line 552:

<comment>When a custom workflow adds an inline YAML comment after `workflow_call:` or `inputs:`, `sync` rejects the valid workflow as missing every required input. Allow an optional inline comment when matching block keys.</comment>

<file context>
@@ -502,6 +505,95 @@ def check_title_format(config: Config) -> None:
+    Returns:
+        The index, or None when no line declares that key with a nested block.
+    """
+    pattern = re.compile(rf"\s*{re.escape(key)}:$")
+    return next(
+        (index for index, line in enumerate(lines) if pattern.fullmatch(line.rstrip())),
</file context>
Suggested change
pattern = re.compile(rf"\s*{re.escape(key)}:$")
pattern = re.compile(rf"\s*{re.escape(key)}:\s*(?:#.*)?$")

Alek99
Alek99 previously approved these changes Aug 15, 2026
#6888 (post-release workflow hook) landed on main and touches the same
generated workflow, so three conflicts needed more than a textual union:

- POST_RELEASE_STEP read `needs.build.outputs.tag`/`version`. This branch
  splits the old build job into prepare/build/collect, and tag-and-release
  now needs [prepare, publish] — so the dispatch reads those facts from
  prepare, the job that computes them. Retargeted, and its test with it.

- main's render() gained "a placeholder alone on a line is an optional
  block: an empty value removes the line". @@DEFAULT_BUILD_GUARD@@ sat at
  the end of the build job's `if`, so an empty value would have eaten the
  newline and folded `runs-on:` into the expression. The guard is now a
  line of its own, and the two optional job/step blocks let the template
  carry their blank separator instead of embedding a trailing newline.

- check_custom_build_workflows rejected naming a generated workflow via
  managed_workflows(), which has the hole main documents for the same guard:
  it drops auto_release_internal.yml exactly when a repository has no
  internal packages. Switched to main's GENERATED_WORKFLOWS, and made the
  custom-build table label public so the two guards name it from one place.

Verified all four combinations of the two features render, parse as YAML
and round-trip through `sync --check`, with a test covering both at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1ujR3svpGxMwm5pBMrsXh
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants