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
4 changes: 4 additions & 0 deletions .github/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
changelog:
exclude:
authors:
- github-actions[bot]
30 changes: 30 additions & 0 deletions .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: PR title

on:
pull_request:
branches:
- main
- release
types:
- opened
- edited
- synchronize
- reopened

permissions:
contents: read

jobs:
pr-title:
name: pr-title
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
sparse-checkout: utils/pr_title.py
sparse-checkout-cone-mode: false

- name: Check the Conventional Commit title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: python3 utils/pr_title.py -- "$PR_TITLE"
15 changes: 14 additions & 1 deletion .github/workflows/release-candidate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ jobs:
source_tree: ${{ steps.source.outputs.tree }}
tag: ${{ steps.contract.outputs.tag }}
version: ${{ steps.contract.outputs.version }}
previous_tag: ${{ steps.source.outputs.previous_tag }}
steps:
- uses: actions/checkout@v7
with:
Expand Down Expand Up @@ -85,6 +86,15 @@ jobs:
[[ "$(jq -r .head.sha <<< "$pr")" == "$HEAD_SHA" ]]
[[ "$(jq -r .head.ref <<< "$pr")" == release-please--branches--* ]]
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
manifest=.release-please-manifest.json
if [[ "$TARGET_BRANCH" == "release" ]]; then
manifest=.release-please-manifest.stable.json
fi
previous_version="$(git show "$BASE_SHA:$manifest" | jq -er '.["."]')"
previous_tag="v$previous_version"
git show-ref --verify --quiet "refs/tags/$previous_tag"
git merge-base --is-ancestor "refs/tags/$previous_tag" "$BASE_SHA"
echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT"

- name: Validate the combined version contract
id: contract
Expand Down Expand Up @@ -152,6 +162,7 @@ jobs:
CONTROL_DIGEST: ${{ needs.containers.outputs.control_digest }}
HEAD_SHA: ${{ inputs.head_sha }}
PRODUCT_DIGEST: ${{ needs.containers.outputs.product_digest }}
PREVIOUS_TAG: ${{ needs.contract.outputs.previous_tag }}
SOURCE_TREE: ${{ needs.contract.outputs.source_tree }}
TAG: ${{ needs.contract.outputs.tag }}
VERSION: ${{ needs.contract.outputs.version }}
Expand All @@ -163,14 +174,16 @@ jobs:
--arg channel "$CHANNEL" \
--arg control_digest "$CONTROL_DIGEST" \
--arg product_digest "$PRODUCT_DIGEST" \
--arg previous_tag "$PREVIOUS_TAG" \
--arg source_sha "$HEAD_SHA" \
--arg source_tree "$SOURCE_TREE" \
--arg tag "$TAG" \
--arg version "$VERSION" \
--arg worker_digest "$WORKER_DIGEST" \
'{candidate_run_id: $candidate_run_id, channel: $channel,
source_sha: $source_sha, source_tree: $source_tree, tag: $tag,
version: $version, containers: {product: $product_digest,
version: $version, previous_tag: $previous_tag,
containers: {product: $product_digest,
control: $control_digest, worker: $worker_digest}}' \
> release-candidate.json

Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/release-to-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ jobs:
product_digest: ${{ steps.candidate.outputs.product_digest }}
source_sha: ${{ steps.source.outputs.sha }}
version: ${{ steps.contract.outputs.version }}
previous_tag: ${{ steps.candidate.outputs.previous_tag }}
worker_digest: ${{ steps.candidate.outputs.worker_digest }}
steps:
- uses: actions/checkout@v7
Expand Down Expand Up @@ -104,10 +105,16 @@ jobs:
[[ "$candidate_tree" == "$release_tree" ]]
[[ "$recorded_tree" == "$release_tree" ]]

previous_tag="$(jq -er '.previous_tag | select(type == "string" and startswith("v"))' "$candidate")"
[[ "$previous_tag" != "$TAG" ]]
git show-ref --verify --quiet "refs/tags/$previous_tag"
git merge-base --is-ancestor "refs/tags/$previous_tag" HEAD

{
echo "product_digest=$(jq -r .containers.product "$candidate")"
echo "control_digest=$(jq -r .containers.control "$candidate")"
echo "worker_digest=$(jq -r .containers.worker "$candidate")"
echo "previous_tag=$previous_tag"
} >> "$GITHUB_OUTPUT"

- name: Prepare release assets and checksums
Expand Down Expand Up @@ -221,8 +228,14 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
VERSION: ${{ needs.resolve.outputs.version }}
PREVIOUS_TAG: ${{ needs.resolve.outputs.previous_tag }}
shell: bash
run: |
gh api --method POST "repos/$GITHUB_REPOSITORY/releases/generate-notes" \
-f tag_name="$TAG" \
-f previous_tag_name="$PREVIOUS_TAG" \
-f configuration_file_path=.github/release.yml \
--jq .body > github-contribution-notes.md
gh release view "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--json body \
Expand All @@ -231,6 +244,7 @@ jobs:
--assets release-assets \
--channel "$CHANNEL" \
--existing release-please-notes.md \
--github-notes github-contribution-notes.md \
--output release-notes.md \
--repository "$GITHUB_REPOSITORY" \
--tag "$TAG" \
Expand Down
7 changes: 6 additions & 1 deletion desktop/src-tauri/build.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
#[path = "src/wheel_version.rs"]
mod wheel_version;

fn main() {
use sha2::{Digest, Sha256};
use std::fmt::Write;
Expand Down Expand Up @@ -29,7 +32,9 @@ fn main() {
let package_version = manifest["package_version"]
.as_str()
.expect("runtime manifest must contain package_version");
let wheel_version = package_version.replace("-b.", "b").replace("-b", "b");
let wheel_version = wheel_version::wheel_version(package_version).unwrap_or_else(|| {
panic!("runtime manifest package_version {package_version:?} has no known wheel version")
});
let wheel_prefix = format!("{}-{wheel_version}-", package_name.replace('-', "_"));
let distribution_directory = Path::new("../..").join("dist");
let wheels = std::fs::read_dir(&distribution_directory)
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ mod media_setup;
mod premiere_integration;
mod query_setup;
mod target_profiles;
#[cfg(test)]
mod wheel_version;

use activation::{ActivationRecovery, ActivationStage, activation_recovery};
use lifecycle::{
Expand Down
38 changes: 38 additions & 0 deletions desktop/src-tauri/src/wheel_version.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Release Please writes `X.Y.Z-b` and `X.Y.Z-b.N`; wheel names carry the PEP 440 form.
pub(crate) fn wheel_version(version: &str) -> Option<String> {
let Some((release, prerelease)) = version.split_once("-b") else {
return Some(version.to_owned());
};
if prerelease.is_empty() {
return Some(format!("{release}b0"));
}
let number = prerelease.strip_prefix('.')?;
if number.is_empty() || !number.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
Some(format!("{release}b{}", number.parse::<u64>().ok()?))
}

#[cfg(test)]
mod tests {
use super::wheel_version;

#[test]
fn release_please_versions_match_python_wheel_names() {
for (version, expected) in [
("0.4.0", "0.4.0"),
("1.0.0-b", "1.0.0b0"),
("0.4.0-b.3", "0.4.0b3"),
("1.0.0-b.12", "1.0.0b12"),
] {
assert_eq!(wheel_version(version).as_deref(), Some(expected), "{version}");
}
}

#[test]
fn unexpected_prerelease_shapes_are_rejected() {
for version in ["1.0.0-b.", "1.0.0-beta", "1.0.0-b.x", "1.0.0-b1"] {
assert_eq!(wheel_version(version), None, "{version}");
}
}
}
3 changes: 2 additions & 1 deletion docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,8 @@ concrete:
- **Validation:** list exact commands and the real boundaries exercised. Do
not replace results with “tests passed.”

Use a Conventional Commit title. Examples:
Use a Conventional Commit title; the `pr-title` check fails until it matches.
Examples:

```text
feat(mcp): add clip artifact discovery
Expand Down
34 changes: 33 additions & 1 deletion docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,44 @@ candidate have the same version and Git tree. It then:
3. uploads the Python distributions, three Desktop packages, and
`SHA256SUMS` to the GitHub release;
4. renders `.github/release-intro.md` with the exact asset names above the
generated changelog; and
generated changelog and adds GitHub's contributor notes; and
5. publishes the GitHub release as a prerelease or the latest stable release.

Publication reuses the candidate artifacts. It does not rebuild them after the
release pull request is merged.

### Contributor attribution

Release Please continues to own version selection and the product changelog.
An expandable **Contributors and pull requests** section below that changelog
contains GitHub's native generated notes: PR author credits, any **New
Contributors** entries, and the full comparison link. The output is preserved
as Markdown rather than parsed into a separate author list. It credits PR
contributions, not every reviewer, issue reporter, or co-author.

The candidate records the previous tag from its target branch's release
manifest at the verified base revision. Beta uses
`.release-please-manifest.json`; stable uses
`.release-please-manifest.stable.json`. Both candidate validation and publication
check that the previous tag exists and is an ancestor of the selected source.
Publication supplies this explicit range to GitHub, instead of asking it to
choose the latest release. `.github/release.yml` excludes release automation
by `github-actions[bot]`; human documentation and test contributions remain
eligible for credit.

A contributor can appear as new in both a beta and its later stable release
when both comparison ranges include their first contribution. This follows
GitHub's attribution behavior; VidXP does not track a separate announcement
history. Nightly TestPyPI packages do not have a GitHub release or this section.

GitHub generates the contribution notes before the final asset upload and
release-page edit. If generation fails, publication stops at that step; PyPI
and container publication may already have completed. Retry the same workflow
after resolving the error. Rendering replaces the previous attribution block
without duplicating it or changing the product changelog. Notes are regenerated
on retry, so later PR-title edits can be reflected even though the tag range
stays fixed.

The Desktop installers contain the candidate's tested VidXP wheel. This allows
managed setup to be tested before that wheel is public. Selected dependencies
still resolve from production PyPI; TestPyPI is used only for nightly package
Expand Down
41 changes: 26 additions & 15 deletions tests/test_ci_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,22 @@ def test_product_and_workflow_changes_validate_containers(self):
self.assertTrue(scope.run_suite)
self.assertTrue(scope.run_container)

def test_desktop_uses_stable_product_and_packaging_boundaries(self):
def test_desktop_installers_build_only_for_what_they_package(self):
for path in (
"desktop/src/App.tsx",
"src/vidxp/settings.py",
"tests/test_packaging.py",
"utils/build_package.sh",
"utils/build-requirements.txt",
"utils/fix_readme_links.py",
"utils/normalize_sdist.py",
"pyproject.toml",
"uv.lock",
"MANIFEST.in",
"setup.py",
"LICENSE",
".github/workflows/ci.yml",
".github/workflows/desktop.yml",
"plugins/vidxp/skills/vidxp-ingest-video/SKILL.md",
"premiere/src/panel.ts",
):
with self.subTest(path=path):
self.assertTrue(classify([path]).run_desktop)
Expand All @@ -58,6 +64,11 @@ def test_desktop_uses_stable_product_and_packaging_boundaries(self):
"docs/releasing.md",
".agents/plugins/marketplace.json",
"web/upload-page/src/app.js",
"src/vidxp/settings.py",
"tests/test_packaging.py",
"utils/ci_scope.py",
".github/workflows/release-please.yml",
".github/workflows/future.yml",
):
with self.subTest(path=path):
self.assertFalse(classify([path]).run_desktop)
Expand Down Expand Up @@ -118,27 +129,27 @@ def test_benchmark_python_keeps_suite_without_product_builds(self):
self.assertEqual(classify(paths), Scope(True, False, False, False))

def test_benchmark_changes_do_not_hide_shared_or_unknown_changes(self):
for path in (
"src/vidxp/core/runner.py",
"src/vidxp/cli.py",
"src/vidxp/benchmarks/requirements.txt",
"pyproject.toml",
"uv.lock",
"MANIFEST.in",
"utils/ci_scope.py",
".github/workflows/future.yml",
"future-product/component.rs",
for path, run_desktop in (
("src/vidxp/core/runner.py", False),
("src/vidxp/cli.py", False),
("src/vidxp/benchmarks/requirements.txt", False),
("pyproject.toml", True),
("uv.lock", True),
("MANIFEST.in", True),
("utils/ci_scope.py", False),
(".github/workflows/future.yml", False),
("future-product/component.rs", True),
):
with self.subTest(path=path):
self.assertEqual(
classify(["src/vidxp/benchmarks/latency.py", path]),
Scope(True, True, True, True),
Scope(True, True, run_desktop, True),
)

def test_unrecognized_tests_keep_existing_validation(self):
self.assertEqual(
classify(["tests/test_new_benchmark_runtime.py"]),
Scope(True, False, True, True),
Scope(True, False, False, True),
)

def test_forced_validation_overrides_benchmark_exemption(self):
Expand Down
43 changes: 43 additions & 0 deletions tests/test_pr_title.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import unittest
from pathlib import Path

from utils.pr_title import EXAMPLES, title_problem

ROOT = Path(__file__).resolve().parents[1]


class PrTitleTests(unittest.TestCase):
def test_conventional_titles_pass(self):
for title in (
*EXAMPLES,
"build(deps): bump the actions group with 3 updates",
"chore(main): release 1.0.0-b",
"feat(search)!: drop the legacy query endpoint",
"refactor!: rename the index command",
"fix(fusion): bound moment components to shared overlapping interval (#87)",
):
with self.subTest(title=title):
self.assertIsNone(title_problem(title))

def test_titles_release_please_would_skip_fail(self):
for title, problem in (
("Show media state in list command (#112)", '": "'),
("feat:add clips", '": "'),
("Fix: keep search form submittable", "type must be one of"),
("feature(mcp): add clips", "type must be one of"),
("fix (mcp): add clips", "type must be one of"),
("fix(): add clips", "scope in parentheses is empty"),
("fix(mcp): ", "subject"),
):
with self.subTest(title=title):
self.assertIn(problem, title_problem(title) or "")

def test_failure_examples_are_the_contributing_guide_examples(self):
guide = (ROOT / "docs" / "CONTRIBUTING.md").read_text(encoding="utf-8")
for example in EXAMPLES:
with self.subTest(example=example):
self.assertIn(f"\n{example}\n", guide)


if __name__ == "__main__":
unittest.main()
Loading
Loading