From ce9d36cab41f2826f8c42f2b5661a909e16506bc Mon Sep 17 00:00:00 2001 From: Brendan Clement Date: Mon, 19 Oct 2026 10:31:24 -0700 Subject: [PATCH 1/5] ci: publish versioned Vale packages automatically --- .github/workflows/vale-release.yml | 76 ++++++++++++++++++++++++++++++ vale/README.md | 33 +++++++++---- 2 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/vale-release.yml diff --git a/.github/workflows/vale-release.yml b/.github/workflows/vale-release.yml new file mode 100644 index 00000000..d8f16d57 --- /dev/null +++ b/.github/workflows/vale-release.yml @@ -0,0 +1,76 @@ +name: Release Vale package + +on: + push: + branches: [main] + paths: + - "vale/LanceDB/meta.json" + - ".github/workflows/vale-release.yml" + workflow_dispatch: + +# Publishing a style package does not publish the documentation site. +permissions: + contents: write + +concurrency: + group: vale-package-release + cancel-in-progress: false + +jobs: + release: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + env: + VALE_VERSION: 3.24.0 + VALE_SHA256: 867534ddb678abca7f214bc4706ead0922bd5252ffe8035fc227013f66c8b214 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install Vale + run: | + archive="vale_${VALE_VERSION}_Linux_64-bit.tar.gz" + curl --fail --location --silent --show-error --output "$RUNNER_TEMP/$archive" \ + "https://github.com/vale-cli/vale/releases/download/v${VALE_VERSION}/$archive" + echo "${VALE_SHA256} $RUNNER_TEMP/$archive" | sha256sum --check + tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" vale + echo "$RUNNER_TEMP" >> "$GITHUB_PATH" + + - name: Test the package + working-directory: vale/LanceDB + run: vale test --coverage styles ../tests + + - name: Build the release archive + id: package + shell: bash + run: | + set -euo pipefail + version="$(jq -er '.version | select(type == "string")' vale/LanceDB/meta.json)" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Expected a stable major.minor.patch package version." >&2 + exit 1 + } + tag="vale-lancedb-v$version" + # Never replace an existing version, including a tag without a release. + status=0 + git ls-remote --exit-code --tags origin "refs/tags/$tag" > /dev/null || status=$? + case "$status" in + 0) echo "$tag already exists. Choose a new version; nothing was published." >&2; exit 1 ;; + 2) ;; + *) echo "Could not check existing tags; stopping." >&2; exit "$status" ;; + esac + git archive --format=zip --prefix=LanceDB/ \ + --mtime="$(git show -s --format=%cI HEAD)" \ + --output="$RUNNER_TEMP/LanceDB.zip" HEAD:vale/LanceDB + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Publish the package + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.package.outputs.tag }} + run: | + gh release create "$RELEASE_TAG" "$RUNNER_TEMP/LanceDB.zip" \ + --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" \ + --title "$RELEASE_TAG" --latest=false \ + --notes "Shared LanceDB Vale rules. Source commit: $GITHUB_SHA. This release does not publish the documentation site." diff --git a/vale/README.md b/vale/README.md index 979fea83..d9daa511 100644 --- a/vale/README.md +++ b/vale/README.md @@ -58,13 +58,26 @@ keeps finding, with a case that it catches and a case that it leaves alone. ## Release -Consumers pin a release by URL, so a change reaches them only when their URL -moves. After a change merges, bump `version` in `LanceDB/meta.json`, then build -the archive from the merged commit and attach it to a release. Archiving a -directory stamps its files with the current time, so set `--mtime` to the -commit time to get the same archive every time: - -```bash -git archive --format=zip --prefix=LanceDB/ --mtime="$(git log -1 --format=%cI )" -o LanceDB.zip :vale/LanceDB -gh release create vale-lancedb-v LanceDB.zip --target -``` +Consumers pin a package release by URL. To release updated rules, change +`version` in `LanceDB/meta.json` in the same pull request as the package changes. +Use a new stable `major.minor.patch` version for each release. + +When that version change merges into `main`, the **Release Vale package** +workflow tests the package, builds `LanceDB.zip` from that exact commit and +publishes `vale-lancedb-v`. The archive uses the commit timestamp so +repeated builds from that commit produce identical bytes. No local ZIP build +or personal access token is needed; the workflow uses GitHub's repository token. + +Adding or changing the release workflow also starts it on `main`. Its initial +merge publishes the current `0.1.0` package if that tag does not already exist. +The workflow can also be started from the Actions page on `main` to retry a +failure before publication. It refuses to replace an existing tag or release; +a run that already published successfully does not need retrying. If a failed +run left a tag or release behind, inspect it before deciding how to recover. + +After publication, update the release URL in each consumer's `.vale.ini` and +check its CI before merging. Consumer updates remain explicit; publishing a +new package does not silently change another repository's rules. + +This workflow publishes only the Vale package. It does not publish staging or +production documentation, deploy Gatekeeper, or update consumer repositories. From 7197868f9c023d33dd74872490695a3bdd67ffcc Mon Sep 17 00:00:00 2001 From: Brendan Clement Date: Mon, 19 Oct 2026 10:51:47 -0700 Subject: [PATCH 2/5] ci: verify existing Vale releases before skipping publication --- .github/workflows/vale-release.yml | 65 ++++++++------ scripts/tests/test_vale_release.py | 136 +++++++++++++++++++++++++++++ scripts/vale_release.py | 119 +++++++++++++++++++++++++ vale/README.md | 24 +++-- 4 files changed, 310 insertions(+), 34 deletions(-) create mode 100644 scripts/tests/test_vale_release.py create mode 100644 scripts/vale_release.py diff --git a/.github/workflows/vale-release.yml b/.github/workflows/vale-release.yml index d8f16d57..b0a01084 100644 --- a/.github/workflows/vale-release.yml +++ b/.github/workflows/vale-release.yml @@ -6,19 +6,25 @@ on: paths: - "vale/LanceDB/meta.json" - ".github/workflows/vale-release.yml" + - "scripts/vale_release.py" + pull_request: + paths: + - "vale/**" + - ".github/workflows/vale-release.yml" + - "scripts/vale_release.py" + - "scripts/tests/test_vale_release.py" workflow_dispatch: # Publishing a style package does not publish the documentation site. permissions: - contents: write + contents: read concurrency: - group: vale-package-release + group: vale-package-${{ github.ref }} cancel-in-progress: false jobs: - release: - if: github.ref == 'refs/heads/main' + validate: runs-on: ubuntu-24.04 env: VALE_VERSION: 3.24.0 @@ -41,36 +47,39 @@ jobs: working-directory: vale/LanceDB run: vale test --coverage styles ../tests - - name: Build the release archive + - name: Test release preparation + run: python3 -B -m unittest discover -s scripts/tests -p test_vale_release.py -v + + - name: Build and verify the package without publishing + env: + GH_TOKEN: ${{ github.token }} + run: python3 -B scripts/vale_release.py --output-dir "$RUNNER_TEMP/vale-release" + + publish: + needs: validate + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + # Recheck in the publishing job in case the release appeared during validation. + - name: Prepare the package id: package - shell: bash - run: | - set -euo pipefail - version="$(jq -er '.version | select(type == "string")' vale/LanceDB/meta.json)" - [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { - echo "Expected a stable major.minor.patch package version." >&2 - exit 1 - } - tag="vale-lancedb-v$version" - # Never replace an existing version, including a tag without a release. - status=0 - git ls-remote --exit-code --tags origin "refs/tags/$tag" > /dev/null || status=$? - case "$status" in - 0) echo "$tag already exists. Choose a new version; nothing was published." >&2; exit 1 ;; - 2) ;; - *) echo "Could not check existing tags; stopping." >&2; exit "$status" ;; - esac - git archive --format=zip --prefix=LanceDB/ \ - --mtime="$(git show -s --format=%cI HEAD)" \ - --output="$RUNNER_TEMP/LanceDB.zip" HEAD:vale/LanceDB - echo "tag=$tag" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ github.token }} + run: python3 -B scripts/vale_release.py --output-dir "$RUNNER_TEMP/vale-release" - - name: Publish the package + - name: Publish a new version + if: steps.package.outputs.exists != 'true' env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ steps.package.outputs.tag }} run: | - gh release create "$RELEASE_TAG" "$RUNNER_TEMP/LanceDB.zip" \ + gh release create "$RELEASE_TAG" "$RUNNER_TEMP/vale-release/LanceDB.zip" \ --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" \ --title "$RELEASE_TAG" --latest=false \ --notes "Shared LanceDB Vale rules. Source commit: $GITHUB_SHA. This release does not publish the documentation site." diff --git a/scripts/tests/test_vale_release.py b/scripts/tests/test_vale_release.py new file mode 100644 index 00000000..ad309d85 --- /dev/null +++ b/scripts/tests/test_vale_release.py @@ -0,0 +1,136 @@ +"""Release checks are read-only; all GitHub responses here are stand-ins.""" + +import importlib.util +import io +import json +from pathlib import Path +import tempfile +import unittest +import warnings +from unittest.mock import patch +from urllib.error import HTTPError +import zipfile + +spec = importlib.util.spec_from_file_location( + "vale_release", Path(__file__).resolve().parents[1] / "vale_release.py" +) +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) + + +def archive(content=b"rule", year=2026, mode=0o100644, extra=False): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as output: + entries = [("LanceDB/meta.json", b'{"version":"0.1.0"}'), + ("LanceDB/styles/LanceDB/Rule.yml", content)] + if extra: + entries.append(("LanceDB/unexpected.txt", b"extra")) + for name, data in entries: + info = zipfile.ZipInfo(name, date_time=(year, 1, 1, 0, 0, 0)) + info.external_attr = mode << 16 + output.writestr(info, data) + return buffer.getvalue() + + +class ReleaseTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.path = Path(self.directory.name) / "LanceDB.zip" + self.path.write_bytes(archive()) + self.ref = {"object": {"type": "commit", "sha": "a" * 40}} + self.metadata = { + "draft": False, "prerelease": False, "tag_name": "vale-lancedb-v0.1.0", + "assets": [{"name": "LanceDB.zip", "state": "uploaded"}], + } + + def check(self, ref, metadata, data=None): + with patch.object(release, "api_json", side_effect=[ref, metadata]), patch.object( + release, "download_asset", return_value=data or archive() + ) as download: + result = release.existing_package_matches( + "lancedb/docs", "vale-lancedb-v0.1.0", self.path + ) + if metadata is None: + download.assert_not_called() + return result + + def test_absent_release_can_publish(self): + self.assertFalse(self.check(None, None)) + + def test_matching_release_skips_even_with_different_zip_dates(self): + self.assertTrue(self.check(self.ref, self.metadata, archive(year=2025))) + + def test_different_rule_requires_new_version(self): + with self.assertRaisesRegex(RuntimeError, "bump the package version"): + self.check(self.ref, self.metadata, archive(content=b"different")) + + def test_extra_file_requires_new_version(self): + with self.assertRaisesRegex(RuntimeError, "different package files"): + self.check(self.ref, self.metadata, archive(extra=True)) + + def test_changed_permissions_require_new_version(self): + with self.assertRaisesRegex(RuntimeError, "different package files"): + self.check(self.ref, self.metadata, archive(mode=0o100755)) + + def test_incomplete_release_stops(self): + for ref, metadata in [(self.ref, None), (None, self.metadata)]: + with self.subTest(ref=ref), self.assertRaisesRegex(RuntimeError, "incomplete"): + self.check(ref, metadata) + + def test_unpublished_or_wrong_release_stops(self): + for key, value in [("draft", True), ("prerelease", True), ("tag_name", "wrong")]: + with self.subTest(key=key), self.assertRaisesRegex(RuntimeError, "stable release"): + self.check(self.ref, {**self.metadata, key: value}) + + def test_missing_duplicate_or_pending_asset_stops(self): + for assets in [[], self.metadata["assets"] * 2, [{"name": "LanceDB.zip", "state": "new"}]]: + with self.subTest(assets=assets), self.assertRaisesRegex(RuntimeError, "fully uploaded"): + self.check(self.ref, {**self.metadata, "assets": assets}) + + def test_corrupt_zip_stops(self): + with self.assertRaises(zipfile.BadZipFile): + self.check(self.ref, self.metadata, b"not a zip") + + def test_duplicate_and_outside_paths_stop(self): + for names in [["LanceDB/meta.json"] * 2, ["outside"], ["LanceDB/../escape"]]: + with self.subTest(names=names): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as output: + for name in names: + # zipfile warns for the intentional duplicate fixture. + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + output.writestr(name, b"{}") + with self.assertRaises(ValueError): + release.package_contents(buffer.getvalue()) + + def test_api_only_treats_404_as_absent(self): + for status in [404, 401, 403, 429, 500]: + with self.subTest(status=status), patch.object( + release, "urlopen", side_effect=HTTPError("https://api.github.com/", status, "failure", {}, None) + ): + if status == 404: + self.assertIsNone(release.api_json("lancedb/docs", "releases/tags/test")) + else: + with self.assertRaisesRegex(RuntimeError, f"HTTP {status}"): + release.api_json("lancedb/docs", "releases/tags/test") + + def test_asset_download_does_not_forward_token(self): + with patch.dict(release.os.environ, {"GH_TOKEN": "test-only-token"}), patch.object(release, "urlopen") as request: + release.download_asset("lancedb/docs", "vale-lancedb-v0.1.0") + self.assertIsInstance(request.call_args.args[0], str) + self.assertEqual(request.call_args.kwargs, {"timeout": 30}) + + def test_invalid_version_stops_before_build(self): + for version in ["bad", "01.2.3", "1.0.0-beta.1", 1]: + with self.subTest(version=version), patch.object( + release.subprocess, "check_output", return_value=json.dumps({"version": version}) + ), patch.object(release.subprocess, "run") as build: + with self.assertRaises(ValueError): + release.build_package(Path(self.directory.name)) + build.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/vale_release.py b/scripts/vale_release.py new file mode 100644 index 00000000..e3eb8d30 --- /dev/null +++ b/scripts/vale_release.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Build the committed Vale package and check whether publication is needed. + +This script only reads GitHub. The workflow owns the separate publication step. +""" + +import argparse +import io +import json +import os +from pathlib import Path +import re +import subprocess +import sys +from urllib.error import HTTPError +from urllib.parse import quote +from urllib.request import Request, urlopen +import zipfile + + +def api_json(repository, path): + url = f"https://api.github.com/repos/{repository}/{path}" + headers = {"Accept": "application/vnd.github+json"} + if os.environ.get("GH_TOKEN"): + headers["Authorization"] = f"Bearer {os.environ['GH_TOKEN']}" + try: + with urlopen(Request(url, headers=headers), timeout=30) as response: + return json.load(response) + except HTTPError as error: + if error.code == 404: + return None + raise RuntimeError(f"GitHub read failed with HTTP {error.code}") from None + + +def download_asset(repository, tag): + # Public asset download; never forward the API token to the asset host. + url = f"https://github.com/{repository}/releases/download/{quote(tag, safe='')}/LanceDB.zip" + with urlopen(url, timeout=30) as response: + return response.read() + + +def package_contents(data): + """Compare package files and permissions, not commit-dependent ZIP dates.""" + contents = {} + with zipfile.ZipFile(io.BytesIO(data)) as archive: + for entry in archive.infolist(): + name = entry.filename + if ( + not name.startswith("LanceDB/") + or ".." in name.split("/") + or name in contents + ): + raise ValueError(f"Invalid or duplicate package entry: {name}") + contents[name] = (entry.external_attr, archive.read(entry)) + if "LanceDB/meta.json" not in contents: + raise ValueError("Package is missing LanceDB/meta.json") + return contents + + +def existing_package_matches(repository, tag, archive): + ref = api_json(repository, f"git/ref/tags/{quote(tag, safe='')}") + release = api_json(repository, f"releases/tags/{quote(tag, safe='')}") + if ref is None and release is None: + return False + if ref is None or release is None: + raise RuntimeError(f"{tag} has an incomplete tag/release; inspect it before retrying") + if release["draft"] or release["prerelease"] or release["tag_name"] != tag: + raise RuntimeError(f"{tag} is not the expected published stable release") + assets = [asset for asset in release["assets"] if asset["name"] == "LanceDB.zip"] + if len(assets) != 1 or assets[0]["state"] != "uploaded": + raise RuntimeError(f"{tag} does not have one fully uploaded LanceDB.zip") + published = download_asset(repository, tag) + if package_contents(published) != package_contents(archive.read_bytes()): + raise RuntimeError(f"{tag} contains different package files; bump the package version") + return True + + +def build_package(output_dir): + def git(*args): + return subprocess.check_output(["git", *args], text=True).strip() + + meta = json.loads(git("show", "HEAD:vale/LanceDB/meta.json")) + version = meta["version"] + if not isinstance(version, str) or not re.fullmatch(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)", version): + raise ValueError("Expected a stable major.minor.patch package version") + output_dir.mkdir(parents=True, exist_ok=True) + archive = output_dir / "LanceDB.zip" + subprocess.run( + [ + "git", "archive", "--format=zip", "--prefix=LanceDB/", + f"--mtime={git('show', '-s', '--format=%cI', 'HEAD')}", + f"--output={archive}", "HEAD:vale/LanceDB", + ], + check=True, + ) + return f"vale-lancedb-v{version}", archive + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY", "lancedb/docs")) + args = parser.parse_args() + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", args.repository): + parser.error("Expected owner/repository") + tag, archive = build_package(args.output_dir.resolve()) + exists = existing_package_matches(args.repository, tag, archive) + if os.environ.get("GITHUB_OUTPUT"): + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"tag={tag}\nexists={str(exists).lower()}\n") + print(f"{tag}: {'matching release exists; skip publication' if exists else 'ready for publication'}") + + +if __name__ == "__main__": + try: + main() + except Exception as error: + print(f"Vale release preparation failed: {error}", file=sys.stderr) + sys.exit(1) diff --git a/vale/README.md b/vale/README.md index d9daa511..6420a273 100644 --- a/vale/README.md +++ b/vale/README.md @@ -68,12 +68,24 @@ publishes `vale-lancedb-v`. The archive uses the commit timestamp so repeated builds from that commit produce identical bytes. No local ZIP build or personal access token is needed; the workflow uses GitHub's repository token. -Adding or changing the release workflow also starts it on `main`. Its initial -merge publishes the current `0.1.0` package if that tag does not already exist. -The workflow can also be started from the Actions page on `main` to retry a -failure before publication. It refuses to replace an existing tag or release; -a run that already published successfully does not need retrying. If a failed -run left a tag or release behind, inspect it before deciding how to recover. +Pull requests run the package tests and release-preparation tests on Ubuntu, +then build and check the actual archive without publishing. Only a successful +run on `main` can enter the separate publishing job with write permission. + +Adding or changing the release workflow or its preparation script also starts +it on `main`. The workflow can be started from the Actions page on `main` to +retry a failure. Before publishing, it checks for the version's tag and release: + +- If neither exists, it publishes the new version. +- If both exist, it downloads `LanceDB.zip` and compares all package entries, + contents and permissions. Matching packages skip publication successfully. + ZIP dates are ignored because unchanged rules can appear in later commits. +- Different contents, a missing or incomplete asset, a draft release, or a tag + without a release stop the run. Nothing is replaced or deleted. Inspect the + failure; use a new version for changed rules. + +The manually published `0.1.0` package is already available. Merging this +workflow verifies that package and skips publication if it matches. After publication, update the release URL in each consumer's `.vale.ini` and check its CI before merging. Consumer updates remain explicit; publishing a From a694b693582c73ca7ed0381a788f69e489aa1415 Mon Sep 17 00:00:00 2001 From: Brendan Clement Date: Mon, 19 Oct 2026 10:55:40 -0700 Subject: [PATCH 3/5] ci: simplify Vale publication to the workflow --- .github/workflows/vale-release.yml | 85 ++++++++++++------ scripts/tests/test_vale_release.py | 136 ----------------------------- scripts/vale_release.py | 119 ------------------------- vale/README.md | 15 ++-- 4 files changed, 66 insertions(+), 289 deletions(-) delete mode 100644 scripts/tests/test_vale_release.py delete mode 100644 scripts/vale_release.py diff --git a/.github/workflows/vale-release.yml b/.github/workflows/vale-release.yml index b0a01084..ccefaed2 100644 --- a/.github/workflows/vale-release.yml +++ b/.github/workflows/vale-release.yml @@ -6,16 +6,12 @@ on: paths: - "vale/LanceDB/meta.json" - ".github/workflows/vale-release.yml" - - "scripts/vale_release.py" pull_request: paths: - "vale/**" - ".github/workflows/vale-release.yml" - - "scripts/vale_release.py" - - "scripts/tests/test_vale_release.py" workflow_dispatch: -# Publishing a style package does not publish the documentation site. permissions: contents: read @@ -24,8 +20,11 @@ concurrency: cancel-in-progress: false jobs: - validate: + package: runs-on: ubuntu-24.04 + outputs: + tag: ${{ steps.package.outputs.tag }} + exists: ${{ steps.package.outputs.exists }} env: VALE_VERSION: 3.24.0 VALE_SHA256: 867534ddb678abca7f214bc4706ead0922bd5252ffe8035fc227013f66c8b214 @@ -43,43 +42,77 @@ jobs: tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" vale echo "$RUNNER_TEMP" >> "$GITHUB_PATH" - - name: Test the package + - name: Test the Vale rules working-directory: vale/LanceDB run: vale test --coverage styles ../tests - - name: Test release preparation - run: python3 -B -m unittest discover -s scripts/tests -p test_vale_release.py -v - - - name: Build and verify the package without publishing + - name: Build and check the package + id: package env: GH_TOKEN: ${{ github.token }} - run: python3 -B scripts/vale_release.py --output-dir "$RUNNER_TEMP/vale-release" + run: | + set -euo pipefail + version="$(jq -er '.version | select(type == "string")' vale/LanceDB/meta.json)" + [[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + tag="vale-lancedb-v$version" + revision=HEAD + exists=false + status=0 + git ls-remote --exit-code --tags origin "refs/tags/$tag" > /dev/null || status=$? + case "$status" in + 0) + git fetch --no-tags origin "refs/tags/$tag" + revision="$(git rev-parse 'FETCH_HEAD^{commit}')" + if [[ "$(git rev-parse "$revision:vale/LanceDB")" != "$(git rev-parse HEAD:vale/LanceDB)" ]]; then + echo "$tag has different package contents. Bump the version." >&2 + exit 1 + fi + [[ "$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease --jq '.isDraft or .isPrerelease')" == false ]] + gh release download "$tag" --repo "$GITHUB_REPOSITORY" --pattern LanceDB.zip --dir "$RUNNER_TEMP/published" + exists=true + ;; + 2) ;; + *) echo "Could not check existing tags; stopping." >&2; exit "$status" ;; + esac + # Rebuild an existing version at its original timestamp for exact comparison. + git archive --format=zip --prefix=LanceDB/ \ + --mtime="$(git show -s --format=%cI "$revision")" \ + --output="$RUNNER_TEMP/LanceDB.zip" "$revision:vale/LanceDB" + if [[ "$exists" == true ]]; then + cmp "$RUNNER_TEMP/LanceDB.zip" "$RUNNER_TEMP/published/LanceDB.zip" + echo "Published package matches; skipping publication." + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "exists=$exists" >> "$GITHUB_OUTPUT" + + - uses: actions/upload-artifact@v4 + if: steps.package.outputs.exists != 'true' + with: + name: vale-package + path: ${{ runner.temp }}/LanceDB.zip + if-no-files-found: error publish: - needs: validate - if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + needs: package + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && needs.package.outputs.exists != 'true' runs-on: ubuntu-24.04 permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 with: - persist-credentials: false - - # Recheck in the publishing job in case the release appeared during validation. - - name: Prepare the package - id: package - env: - GH_TOKEN: ${{ github.token }} - run: python3 -B scripts/vale_release.py --output-dir "$RUNNER_TEMP/vale-release" + name: vale-package + path: ${{ runner.temp }}/vale-package - name: Publish a new version - if: steps.package.outputs.exists != 'true' env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ steps.package.outputs.tag }} + RELEASE_TAG: ${{ needs.package.outputs.tag }} run: | - gh release create "$RELEASE_TAG" "$RUNNER_TEMP/vale-release/LanceDB.zip" \ - --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" \ + # Atomically reserve the tag; never reuse a tag created by another run. + gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + -f "ref=refs/tags/$RELEASE_TAG" -f "sha=$GITHUB_SHA" + gh release create "$RELEASE_TAG" "$RUNNER_TEMP/vale-package/LanceDB.zip" \ + --repo "$GITHUB_REPOSITORY" --verify-tag \ --title "$RELEASE_TAG" --latest=false \ --notes "Shared LanceDB Vale rules. Source commit: $GITHUB_SHA. This release does not publish the documentation site." diff --git a/scripts/tests/test_vale_release.py b/scripts/tests/test_vale_release.py deleted file mode 100644 index ad309d85..00000000 --- a/scripts/tests/test_vale_release.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Release checks are read-only; all GitHub responses here are stand-ins.""" - -import importlib.util -import io -import json -from pathlib import Path -import tempfile -import unittest -import warnings -from unittest.mock import patch -from urllib.error import HTTPError -import zipfile - -spec = importlib.util.spec_from_file_location( - "vale_release", Path(__file__).resolve().parents[1] / "vale_release.py" -) -release = importlib.util.module_from_spec(spec) -spec.loader.exec_module(release) - - -def archive(content=b"rule", year=2026, mode=0o100644, extra=False): - buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w") as output: - entries = [("LanceDB/meta.json", b'{"version":"0.1.0"}'), - ("LanceDB/styles/LanceDB/Rule.yml", content)] - if extra: - entries.append(("LanceDB/unexpected.txt", b"extra")) - for name, data in entries: - info = zipfile.ZipInfo(name, date_time=(year, 1, 1, 0, 0, 0)) - info.external_attr = mode << 16 - output.writestr(info, data) - return buffer.getvalue() - - -class ReleaseTests(unittest.TestCase): - def setUp(self): - self.directory = tempfile.TemporaryDirectory() - self.addCleanup(self.directory.cleanup) - self.path = Path(self.directory.name) / "LanceDB.zip" - self.path.write_bytes(archive()) - self.ref = {"object": {"type": "commit", "sha": "a" * 40}} - self.metadata = { - "draft": False, "prerelease": False, "tag_name": "vale-lancedb-v0.1.0", - "assets": [{"name": "LanceDB.zip", "state": "uploaded"}], - } - - def check(self, ref, metadata, data=None): - with patch.object(release, "api_json", side_effect=[ref, metadata]), patch.object( - release, "download_asset", return_value=data or archive() - ) as download: - result = release.existing_package_matches( - "lancedb/docs", "vale-lancedb-v0.1.0", self.path - ) - if metadata is None: - download.assert_not_called() - return result - - def test_absent_release_can_publish(self): - self.assertFalse(self.check(None, None)) - - def test_matching_release_skips_even_with_different_zip_dates(self): - self.assertTrue(self.check(self.ref, self.metadata, archive(year=2025))) - - def test_different_rule_requires_new_version(self): - with self.assertRaisesRegex(RuntimeError, "bump the package version"): - self.check(self.ref, self.metadata, archive(content=b"different")) - - def test_extra_file_requires_new_version(self): - with self.assertRaisesRegex(RuntimeError, "different package files"): - self.check(self.ref, self.metadata, archive(extra=True)) - - def test_changed_permissions_require_new_version(self): - with self.assertRaisesRegex(RuntimeError, "different package files"): - self.check(self.ref, self.metadata, archive(mode=0o100755)) - - def test_incomplete_release_stops(self): - for ref, metadata in [(self.ref, None), (None, self.metadata)]: - with self.subTest(ref=ref), self.assertRaisesRegex(RuntimeError, "incomplete"): - self.check(ref, metadata) - - def test_unpublished_or_wrong_release_stops(self): - for key, value in [("draft", True), ("prerelease", True), ("tag_name", "wrong")]: - with self.subTest(key=key), self.assertRaisesRegex(RuntimeError, "stable release"): - self.check(self.ref, {**self.metadata, key: value}) - - def test_missing_duplicate_or_pending_asset_stops(self): - for assets in [[], self.metadata["assets"] * 2, [{"name": "LanceDB.zip", "state": "new"}]]: - with self.subTest(assets=assets), self.assertRaisesRegex(RuntimeError, "fully uploaded"): - self.check(self.ref, {**self.metadata, "assets": assets}) - - def test_corrupt_zip_stops(self): - with self.assertRaises(zipfile.BadZipFile): - self.check(self.ref, self.metadata, b"not a zip") - - def test_duplicate_and_outside_paths_stop(self): - for names in [["LanceDB/meta.json"] * 2, ["outside"], ["LanceDB/../escape"]]: - with self.subTest(names=names): - buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w") as output: - for name in names: - # zipfile warns for the intentional duplicate fixture. - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - output.writestr(name, b"{}") - with self.assertRaises(ValueError): - release.package_contents(buffer.getvalue()) - - def test_api_only_treats_404_as_absent(self): - for status in [404, 401, 403, 429, 500]: - with self.subTest(status=status), patch.object( - release, "urlopen", side_effect=HTTPError("https://api.github.com/", status, "failure", {}, None) - ): - if status == 404: - self.assertIsNone(release.api_json("lancedb/docs", "releases/tags/test")) - else: - with self.assertRaisesRegex(RuntimeError, f"HTTP {status}"): - release.api_json("lancedb/docs", "releases/tags/test") - - def test_asset_download_does_not_forward_token(self): - with patch.dict(release.os.environ, {"GH_TOKEN": "test-only-token"}), patch.object(release, "urlopen") as request: - release.download_asset("lancedb/docs", "vale-lancedb-v0.1.0") - self.assertIsInstance(request.call_args.args[0], str) - self.assertEqual(request.call_args.kwargs, {"timeout": 30}) - - def test_invalid_version_stops_before_build(self): - for version in ["bad", "01.2.3", "1.0.0-beta.1", 1]: - with self.subTest(version=version), patch.object( - release.subprocess, "check_output", return_value=json.dumps({"version": version}) - ), patch.object(release.subprocess, "run") as build: - with self.assertRaises(ValueError): - release.build_package(Path(self.directory.name)) - build.assert_not_called() - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/vale_release.py b/scripts/vale_release.py deleted file mode 100644 index e3eb8d30..00000000 --- a/scripts/vale_release.py +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env python3 -"""Build the committed Vale package and check whether publication is needed. - -This script only reads GitHub. The workflow owns the separate publication step. -""" - -import argparse -import io -import json -import os -from pathlib import Path -import re -import subprocess -import sys -from urllib.error import HTTPError -from urllib.parse import quote -from urllib.request import Request, urlopen -import zipfile - - -def api_json(repository, path): - url = f"https://api.github.com/repos/{repository}/{path}" - headers = {"Accept": "application/vnd.github+json"} - if os.environ.get("GH_TOKEN"): - headers["Authorization"] = f"Bearer {os.environ['GH_TOKEN']}" - try: - with urlopen(Request(url, headers=headers), timeout=30) as response: - return json.load(response) - except HTTPError as error: - if error.code == 404: - return None - raise RuntimeError(f"GitHub read failed with HTTP {error.code}") from None - - -def download_asset(repository, tag): - # Public asset download; never forward the API token to the asset host. - url = f"https://github.com/{repository}/releases/download/{quote(tag, safe='')}/LanceDB.zip" - with urlopen(url, timeout=30) as response: - return response.read() - - -def package_contents(data): - """Compare package files and permissions, not commit-dependent ZIP dates.""" - contents = {} - with zipfile.ZipFile(io.BytesIO(data)) as archive: - for entry in archive.infolist(): - name = entry.filename - if ( - not name.startswith("LanceDB/") - or ".." in name.split("/") - or name in contents - ): - raise ValueError(f"Invalid or duplicate package entry: {name}") - contents[name] = (entry.external_attr, archive.read(entry)) - if "LanceDB/meta.json" not in contents: - raise ValueError("Package is missing LanceDB/meta.json") - return contents - - -def existing_package_matches(repository, tag, archive): - ref = api_json(repository, f"git/ref/tags/{quote(tag, safe='')}") - release = api_json(repository, f"releases/tags/{quote(tag, safe='')}") - if ref is None and release is None: - return False - if ref is None or release is None: - raise RuntimeError(f"{tag} has an incomplete tag/release; inspect it before retrying") - if release["draft"] or release["prerelease"] or release["tag_name"] != tag: - raise RuntimeError(f"{tag} is not the expected published stable release") - assets = [asset for asset in release["assets"] if asset["name"] == "LanceDB.zip"] - if len(assets) != 1 or assets[0]["state"] != "uploaded": - raise RuntimeError(f"{tag} does not have one fully uploaded LanceDB.zip") - published = download_asset(repository, tag) - if package_contents(published) != package_contents(archive.read_bytes()): - raise RuntimeError(f"{tag} contains different package files; bump the package version") - return True - - -def build_package(output_dir): - def git(*args): - return subprocess.check_output(["git", *args], text=True).strip() - - meta = json.loads(git("show", "HEAD:vale/LanceDB/meta.json")) - version = meta["version"] - if not isinstance(version, str) or not re.fullmatch(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)", version): - raise ValueError("Expected a stable major.minor.patch package version") - output_dir.mkdir(parents=True, exist_ok=True) - archive = output_dir / "LanceDB.zip" - subprocess.run( - [ - "git", "archive", "--format=zip", "--prefix=LanceDB/", - f"--mtime={git('show', '-s', '--format=%cI', 'HEAD')}", - f"--output={archive}", "HEAD:vale/LanceDB", - ], - check=True, - ) - return f"vale-lancedb-v{version}", archive - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--output-dir", type=Path, required=True) - parser.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY", "lancedb/docs")) - args = parser.parse_args() - if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", args.repository): - parser.error("Expected owner/repository") - tag, archive = build_package(args.output_dir.resolve()) - exists = existing_package_matches(args.repository, tag, archive) - if os.environ.get("GITHUB_OUTPUT"): - with open(os.environ["GITHUB_OUTPUT"], "a") as output: - output.write(f"tag={tag}\nexists={str(exists).lower()}\n") - print(f"{tag}: {'matching release exists; skip publication' if exists else 'ready for publication'}") - - -if __name__ == "__main__": - try: - main() - except Exception as error: - print(f"Vale release preparation failed: {error}", file=sys.stderr) - sys.exit(1) diff --git a/vale/README.md b/vale/README.md index 6420a273..f524c4dd 100644 --- a/vale/README.md +++ b/vale/README.md @@ -68,18 +68,17 @@ publishes `vale-lancedb-v`. The archive uses the commit timestamp so repeated builds from that commit produce identical bytes. No local ZIP build or personal access token is needed; the workflow uses GitHub's repository token. -Pull requests run the package tests and release-preparation tests on Ubuntu, -then build and check the actual archive without publishing. Only a successful -run on `main` can enter the separate publishing job with write permission. +Pull requests run the existing Vale rule tests on Ubuntu, then build and check +the archive without publishing. Only a successful run on `main` can enter the +separate publishing job with write permission. -Adding or changing the release workflow or its preparation script also starts -it on `main`. The workflow can be started from the Actions page on `main` to +Adding or changing the release workflow also starts it on `main`. The workflow can be started from the Actions page on `main` to retry a failure. Before publishing, it checks for the version's tag and release: - If neither exists, it publishes the new version. -- If both exist, it downloads `LanceDB.zip` and compares all package entries, - contents and permissions. Matching packages skip publication successfully. - ZIP dates are ignored because unchanged rules can appear in later commits. +- If both exist, it verifies that the tagged package source matches the current + package, rebuilds it using the original tag timestamp, and compares the ZIP + with the published download. An exact match skips publication successfully. - Different contents, a missing or incomplete asset, a draft release, or a tag without a release stop the run. Nothing is replaced or deleted. Inspect the failure; use a new version for changed rules. From b6c69e49d03b4ebd008b86ef82330829da1e5287 Mon Sep 17 00:00:00 2001 From: Brendan Clement Date: Mon, 19 Oct 2026 10:56:30 -0700 Subject: [PATCH 4/5] ci: compare Vale package files across runner platforms --- .github/workflows/vale-release.yml | 7 +++++-- vale/README.md | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/vale-release.yml b/.github/workflows/vale-release.yml index ccefaed2..5d29fd3b 100644 --- a/.github/workflows/vale-release.yml +++ b/.github/workflows/vale-release.yml @@ -74,12 +74,15 @@ jobs: 2) ;; *) echo "Could not check existing tags; stopping." >&2; exit "$status" ;; esac - # Rebuild an existing version at its original timestamp for exact comparison. + # Build from the verified source commit. git archive --format=zip --prefix=LanceDB/ \ --mtime="$(git show -s --format=%cI "$revision")" \ --output="$RUNNER_TEMP/LanceDB.zip" "$revision:vale/LanceDB" if [[ "$exists" == true ]]; then - cmp "$RUNNER_TEMP/LanceDB.zip" "$RUNNER_TEMP/published/LanceDB.zip" + # ZIP timestamps can vary across platforms; compare the actual files. + unzip -q "$RUNNER_TEMP/LanceDB.zip" -d "$RUNNER_TEMP/built-files" + unzip -q "$RUNNER_TEMP/published/LanceDB.zip" -d "$RUNNER_TEMP/published-files" + diff -r "$RUNNER_TEMP/built-files" "$RUNNER_TEMP/published-files" echo "Published package matches; skipping publication." fi echo "tag=$tag" >> "$GITHUB_OUTPUT" diff --git a/vale/README.md b/vale/README.md index f524c4dd..61f69695 100644 --- a/vale/README.md +++ b/vale/README.md @@ -65,7 +65,7 @@ Use a new stable `major.minor.patch` version for each release. When that version change merges into `main`, the **Release Vale package** workflow tests the package, builds `LanceDB.zip` from that exact commit and publishes `vale-lancedb-v`. The archive uses the commit timestamp so -repeated builds from that commit produce identical bytes. No local ZIP build +repeated builds from that commit on the same toolchain produce identical bytes. No local ZIP build or personal access token is needed; the workflow uses GitHub's repository token. Pull requests run the existing Vale rule tests on Ubuntu, then build and check @@ -77,8 +77,8 @@ retry a failure. Before publishing, it checks for the version's tag and release: - If neither exists, it publishes the new version. - If both exist, it verifies that the tagged package source matches the current - package, rebuilds it using the original tag timestamp, and compares the ZIP - with the published download. An exact match skips publication successfully. + package, rebuilds it, and compares the unpacked files with the published + download. Matching files skip publication; ZIP timestamps are ignored. - Different contents, a missing or incomplete asset, a draft release, or a tag without a release stop the run. Nothing is replaced or deleted. Inspect the failure; use a new version for changed rules. From 9954fd9d87678ce81d0cf86dc0f67a358c81b871 Mon Sep 17 00:00:00 2001 From: Brendan Clement Date: Mon, 19 Oct 2026 11:09:20 -0700 Subject: [PATCH 5/5] ci: queue pending Vale package releases --- .github/workflows/vale-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/vale-release.yml b/.github/workflows/vale-release.yml index 5d29fd3b..4e9ffeea 100644 --- a/.github/workflows/vale-release.yml +++ b/.github/workflows/vale-release.yml @@ -17,6 +17,7 @@ permissions: concurrency: group: vale-package-${{ github.ref }} + queue: max cancel-in-progress: false jobs: