Skip to content
Closed
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
888 changes: 888 additions & 0 deletions .pipeline/OneBranch/PublishFeeds-Sandbox.yml

Large diffs are not rendered by default.

86 changes: 86 additions & 0 deletions .pipeline/scripts/check-crate-version-not-published.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

<#
.SYNOPSIS
Preflight guard for the sandbox crate release flow.

.DESCRIPTION
Fails (exit 1) when a crate version about to be published is already present
in the Azure Artifacts Cargo registry, so the pipeline fails in seconds
instead of building and only hitting cargo's duplicate-version rejection at
`cargo publish`.

The mssql-rs_Public feed allows anonymous reads, so the PEP-equivalent Cargo
sparse index is queried without credentials. Each crate's index file is
newline-delimited JSON, one object per published version.

Best-effort: if the registry cannot be reached (auth/network), the script
WARNs and exits 0. cargo's duplicate-version rejection at publish time remains
the authoritative guard, so a transient lookup failure never blocks a release.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$IndexBaseUrl,
[Parameter(Mandatory = $true)][string]$Version,
[Parameter(Mandatory = $true)][string[]]$Crates
)

$ErrorActionPreference = 'Stop'

function Get-CargoIndexPath([string]$name) {
$n = $name.ToLower()
switch ($n.Length) {
1 { return "1/$n" }
2 { return "2/$n" }
3 { return "3/$($n[0])/$n" }
default { return "$($n.Substring(0,2))/$($n.Substring(2,2))/$n" }
}
}

$base = $IndexBaseUrl.TrimEnd('/')
$conflict = $false

foreach ($crate in $Crates) {
$url = "$base/$(Get-CargoIndexPath $crate)"
Write-Host "Release preflight: checking registry for $crate@$Version"
Write-Host " GET $url"

$content = $null
try {
$resp = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
$content = $resp.Content
}
catch {
$code = $null
if ($_.Exception.Response) { $code = [int]$_.Exception.Response.StatusCode }
if ($code -eq 404) {
Write-Host " OK: $crate has no published versions yet."
continue
}
Write-Host "##vso[task.logissue type=warning]Registry lookup for $crate failed (HTTP $code); skipping preflight for this crate."
continue
}

$versions = @()
foreach ($line in ($content -split "`n")) {
$line = $line.Trim()
if (-not $line) { continue }
try { $versions += ($line | ConvertFrom-Json).vers } catch { }
}

if ($versions -contains $Version) {
Write-Host "##vso[task.logissue type=error]$crate@$Version is already published to the registry."
$conflict = $true
}
else {
Write-Host " OK: $crate@$Version is not on the registry."
}
}

if ($conflict) {
Write-Error "One or more crate versions are already published. Bump the [package].version in the crate Cargo.toml before running a release. Azure Artifacts rejects re-publishing an existing version."
exit 1
}

Write-Host "All crate versions are clear to publish."
156 changes: 156 additions & 0 deletions .pipeline/scripts/check-version-not-published.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

"""Preflight guard for the sandbox release flow.

Reads the base ``version`` from a pyproject.toml and fails (exit 1) when that
exact version is already published to the Azure Artifacts PyPI feed. This lets
the pipeline fail in seconds instead of building seven wheels and only hitting
the duplicate-version rejection at ``twine upload``.

The ``mssql-rs_Public`` feed allows anonymous reads, so the simple index URL is
passed in directly (no credentials). For convenience an embedded-credential URL
(``https://user:token@.../pypi/simple/``) is still accepted, as is a fallback to
the ``PIP_INDEX_URL`` environment variable.

Best-effort: if the feed cannot be reached or the index URL is missing, we WARN
and exit 0. The duplicate-version rejection at upload time remains the
authoritative guard, so we never block a release on a transient lookup failure.
"""

import base64
import re
import sys
import urllib.error
import urllib.request
from html.parser import HTMLParser
from urllib.parse import urlsplit, urlunsplit


def read_base_version(pyproject_path: str) -> str:
with open(pyproject_path, encoding="utf-8") as f:
text = f.read()
m = re.search(r'(?m)^version\s*=\s*"([^"]+)"', text)
if not m:
sys.exit(f"ERROR: could not read version from {pyproject_path}")
return m.group(1).strip()


def normalize_name(name: str) -> str:
return re.sub(r"[-_.]+", "-", name).lower()


def normalize_version(version: str) -> str:
# Light PEP 440 normalization: lowercase, drop a leading 'v', collapse the
# SemVer '-dev' / '-rc' style separators maturin emits to PEP 440 form so a
# filename version compares equal to the manifest version.
v = version.strip().lower()
if v.startswith("v"):
v = v[1:]
v = v.replace("-dev", ".dev").replace("-rc", "rc").replace("-alpha", "a").replace("-beta", "b")
return v


class _AnchorParser(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.filenames: list[str] = []

def handle_data(self, data: str) -> None:
data = data.strip()
if data.endswith((".whl", ".tar.gz", ".zip")):
self.filenames.append(data)


def version_from_filename(filename: str) -> str | None:
if filename.endswith(".whl"):
parts = filename[:-4].split("-")
return parts[1] if len(parts) >= 2 else None
for ext in (".tar.gz", ".zip"):
if filename.endswith(ext):
stem = filename[: -len(ext)]
bits = stem.rsplit("-", 1)
return bits[1] if len(bits) == 2 else None
return None


def fetch_simple_page(index_url: str, package: str) -> str | None:
parts = urlsplit(index_url)
auth_header = None
netloc = parts.netloc
if "@" in netloc:
userinfo, host = netloc.rsplit("@", 1)
netloc = host
token = base64.b64encode(userinfo.encode("utf-8")).decode("ascii")
auth_header = f"Basic {token}"

base = urlunsplit((parts.scheme, netloc, parts.path, "", ""))
if not base.endswith("/"):
base += "/"
url = f"{base}{normalize_name(package)}/"

req = urllib.request.Request(url)
if auth_header:
req.add_header("Authorization", auth_header)
req.add_header("Accept", "text/html")
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
if e.code == 404:
return "" # package not present at all -> no published versions
print(f"WARNING: feed lookup failed (HTTP {e.code}); skipping preflight.")
return None
except (urllib.error.URLError, TimeoutError) as e:
print(f"WARNING: feed unreachable ({e}); skipping preflight.")
return None


def main(argv: list[str]) -> int:
if len(argv) not in (3, 4):
sys.exit(
"usage: check-version-not-published.py <pyproject.toml> <package-name> [simple-index-url]"
)
pyproject_path, package = argv[1], argv[2]

base = read_base_version(pyproject_path)
target = normalize_version(base)
print(f"Release preflight: checking feed for {package}=={base} (normalized {target})")

import os

index_url = (argv[3] if len(argv) == 4 else os.environ.get("PIP_INDEX_URL", "")).strip()
if not index_url:
print("WARNING: no simple index URL provided; skipping preflight (upload still guards).")
return 0

page = fetch_simple_page(index_url, package)
if page is None:
return 0 # best-effort: could not determine
if page == "":
print(f"OK: {package} has no published versions yet.")
return 0

parser = _AnchorParser()
parser.feed(page)
published = set()
for fn in parser.filenames:
v = version_from_filename(fn)
if v:
published.add(normalize_version(v))

if target in published:
print(
f"ERROR: {package}=={base} is already published to the feed.\n"
f" Bump 'version' in {pyproject_path} before running a release.\n"
f" Azure Artifacts rejects re-uploading an existing version."
)
return 1

print(f"OK: {package}=={base} is not on the feed; safe to release.")
return 0


if __name__ == "__main__":
raise SystemExit(main(sys.argv))
7 changes: 7 additions & 0 deletions mssql-mock-tds-py/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ publish = false
name = "mssql_mock_tds_py"
crate-type = ["cdylib"]

[features]
default = []
# Statically link OpenSSL into the extension. Used for portable macOS wheels so
# the build doesn't dynamically link Homebrew's libssl. Passes through to the
# mssql-mock-tds crate's own vendored-openssl feature (openssl/vendored).
vendored-openssl = ["mssql-mock-tds/vendored-openssl"]

[dependencies]
pyo3 = { version = "0.29.0", features = ["extension-module", "abi3-py39"] }
mssql-mock-tds = { path = "../mssql-mock-tds" }
Expand Down
3 changes: 2 additions & 1 deletion mssql-mock-tds-py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ requires = ["maturin>=1.4,<2.0"]
build-backend = "maturin"

[project]
name = "mssql-mock-tds-py"
name = "mssql-mock-tds"
version = "0.1.0"
description = "Python bindings for the mock TDS server for testing SQL Server clients"
requires-python = ">=3.9"
Expand All @@ -18,4 +18,5 @@ classifiers = [
]

[tool.maturin]
module-name = "mssql_mock_tds"
features = ["pyo3/extension-module"]
3 changes: 2 additions & 1 deletion mssql-mock-tds-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,8 @@ impl PyMockTdsServer {

/// Python module for mock TDS server bindings
#[pymodule]
fn mssql_mock_tds_py(m: &Bound<'_, PyModule>) -> PyResult<()> {
#[pyo3(name = "mssql_mock_tds")]
fn mssql_mock_tds_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyMockTdsServer>()?;
m.add_class::<PyConnectionInfo>()?;
Ok(())
Expand Down
Loading
Loading