Skip to content
Open
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
114 changes: 61 additions & 53 deletions .github/workflows/build-binaries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
persist-credentials: false
- uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
- name: Cache rust
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
shared-key: sdist
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}
Expand Down Expand Up @@ -72,6 +77,7 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
persist-credentials: false
- name: "Install fresh upstream rustup"
# The macos-15-arm64 runner image (20260511.0048+) ships a broken
# Homebrew rustup whose bundled rustup-init rejects standard argv
Expand All @@ -92,6 +98,10 @@ jobs:
# freshly-installed upstream rustup with a stale (and possibly
# Homebrew-linked) binary from a previous run.
cache-bin: false
- name: Cache rust
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
shared-key: py${{ matrix.python-version }}-macos-x86_64
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
Expand All @@ -117,6 +127,7 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
persist-credentials: false
- name: "Install fresh upstream rustup"
# The macos-15-arm64 runner image (20260511.0048+) ships a broken
# Homebrew rustup whose bundled rustup-init rejects standard argv
Expand All @@ -137,6 +148,10 @@ jobs:
# freshly-installed upstream rustup with a stale (and possibly
# Homebrew-linked) binary from a previous run.
cache-bin: false
- name: Cache rust
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
shared-key: py${{ matrix.python-version }}-macos-aarch64
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
Expand Down Expand Up @@ -170,7 +185,12 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
persist-credentials: false
- uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
- name: Cache rust
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
shared-key: py${{ matrix.python-version }}-${{ matrix.platform.target }}
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
Expand Down Expand Up @@ -204,16 +224,20 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
persist-credentials: false
- uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
- name: Cache rust
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
shared-key: py${{ matrix.python-version }}-${{ matrix.platform.target }}
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
architecture: x64
- name: Build wheel
uses: PyO3/maturin-action@v1.50.1
with:
target: ${{ matrix.platform.target }}
manylinux: auto
manylinux: 2_28
args: --release --locked --out dist -i python${{ matrix.python-version }}
before-script-linux: |
# If we're running on rhel centos, install needed packages.
Expand All @@ -237,23 +261,24 @@ jobs:
name: wheels-${{ matrix.platform.target }}-py${{ matrix.python-version }}
path: dist

linux-cross:
linux-arm:
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-build') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
strategy:
matrix:
platform:
- target: aarch64-unknown-linux-gnu
arch: aarch64
# see https://github.com/astral-sh/ruff/issues/3791
# and https://github.com/gnzlbg/jemallocator/issues/170#issuecomment-1503228963
maturin_docker_options: -e JEMALLOC_SYS_WITH_LG_PAGE=16 -e PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1
python-version: ["3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
persist-credentials: false
- uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
- name: Cache rust
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
shared-key: py${{ matrix.python-version }}-${{ matrix.platform.target }}
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
Expand All @@ -262,35 +287,14 @@ jobs:
with:
target: ${{ matrix.platform.target }}
manylinux: 2_28
docker-options: ${{ matrix.platform.maturin_docker_options }}
args: --release --locked --out dist -i ${{ format('python{0}', matrix.python-version) }}
env:
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
- uses: uraimo/run-on-arch-action@v3
if: ${{ matrix.platform.arch != 'ppc64' && matrix.platform.arch != 'ppc64le'}}
name: Test wheel
with:
arch: ${{ matrix.platform.arch }}
distro: ubuntu24.04
githubToken: ${{ github.token }}
install: |
apt-get update
apt-get install -y software-properties-common
add-apt-repository ppa:deadsnakes/ppa
apt-get update
apt-get install -y --no-install-recommends ${{ format('python{0}-full', matrix.python-version) }} ${{ format('python{0}-venv', matrix.python-version) }} cargo libffi-dev
# Create and use a virtual environment to avoid the externally-managed-environment error
run: |
ln -s -f /usr/bin/${{ format('python{0}', matrix.python-version) }} /usr/bin/python3
ln -s -f /usr/bin/${{ format('pip{0}', matrix.python-version) }} /usr/bin/pip3
# Workaround for QEMU bug when emulating 32-bit on 64-bit hosts
# See: https://github.com/docker/buildx/issues/395
# Use /tmp for CARGO_HOME (often tmpfs, which avoids the QEMU filesystem bug)
export CARGO_HOME=/tmp/cargo-home
python3 -m venv /tmp/venv
. /tmp/venv/bin/activate
pip3 install dist/*.whl --force-reinstall
${{ env.EXECUTABLE_NAME }} --help
args: --release --locked --out dist -i python${{ matrix.python-version }}
- name: Test wheel
if: ${{ startsWith(matrix.platform.target, 'aarch64') }}
run: |
PYTAG="cp$(python -c "import sys; print(f'{sys.version_info.major}{sys.version_info.minor}')")"
pip install dist/*-${PYTAG}-*.whl --force-reinstall
${{ env.EXECUTABLE_NAME }} --help
python -m ${{ env.MODULE_NAME }} --help
- name: Upload wheel
uses: actions/upload-artifact@v6
with:
Expand All @@ -309,11 +313,14 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
persist-credentials: false
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install OpenSSL
run: sudo apt-get update && sudo apt-get install libssl-dev openssl
- name: Cache rust
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
shared-key: py${{ matrix.python-version }}-${{ matrix.platform.target }}
- name: Build wheel
uses: PyO3/maturin-action@v1.50.1
with:
Expand All @@ -337,40 +344,41 @@ jobs:
name: wheels-${{ matrix.platform.target }}-py${{ matrix.python-version }}
path: dist

musllinux-cross:
musllinux-arm:
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-build') }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
strategy:
matrix:
platform:
- target: aarch64-unknown-linux-musl
arch: aarch64
python-version: ["3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
persist-credentials: false
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Cache rust
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
shared-key: py${{ matrix.python-version }}-${{ matrix.platform.target }}
- name: Build wheel
uses: PyO3/maturin-action@v1.50.1
with:
target: ${{ matrix.platform.target }}
manylinux: musllinux_1_2
args: --release --locked --out dist -i python${{ matrix.python-version }}
- uses: uraimo/run-on-arch-action@v2
name: Test wheel
with:
arch: ${{ matrix.platform.arch }}
distro: alpine_latest
githubToken: ${{ github.token }}
install: |
apk add uv rust
- name: Test wheel
if: matrix.platform.target == 'aarch64-unknown-linux-musl'
uses: addnab/docker-run-action@v3
with:
image: python:${{ matrix.python-version}}-alpine
options: -v ${{ github.workspace }}:/io -w /io
run: |
uv python install ${{ matrix.python-version }} --default
export PATH="/root/.local/bin:$PATH"
python3 -m venv .venv
apk add rust
python -m venv .venv
.venv/bin/pip3 install dist/*.whl --force-reinstall
.venv/bin/${{ env.EXECUTABLE_NAME }} --help
- name: Upload wheel
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/config/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ url = { workspace = true }
tower-api = { workspace = true }
tower-package = { workspace = true }
tower-telemetry = { workspace = true }

[dev-dependencies]
tempfile = { workspace = true }
4 changes: 3 additions & 1 deletion crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ pub use error::Error;
pub use session::{default_tower_url, Session, Team, Token, User};
pub use tower_package::{Parameter, Towerfile};

pub use session::{get_last_version_check_timestamp, set_last_version_check_timestamp};
pub use session::{
claim_notice, get_last_version_check_timestamp, set_last_version_check_timestamp,
};

#[derive(Clone, Serialize, Deserialize)]
pub struct Config {
Expand Down
104 changes: 102 additions & 2 deletions crates/config/src/session.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use chrono::{DateTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::fs::{self, OpenOptions};
use std::future::Future;
use std::path::PathBuf;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use url::Url;

use crate::error::Error;
Expand Down Expand Up @@ -87,6 +88,43 @@ fn find_or_create_config_dir() -> Result<PathBuf, Error> {
Ok(config_dir)
}

/// Atomically claims a persistent, user-level CLI notice.
///
/// Returns `true` only for the first process to claim this notice ID. Notice
/// IDs are internal constants and may contain ASCII letters, digits, `-`, and
/// `_` only.
pub fn claim_notice(id: &str) -> std::io::Result<bool> {
let config_dir = find_or_create_config_dir()
.map_err(|err| std::io::Error::other(format!("finding Tower config directory: {err}")))?;
claim_notice_at(&config_dir, id)
}

fn claim_notice_at(base_dir: &Path, id: &str) -> std::io::Result<bool> {
if id.is_empty()
|| !id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
"invalid notice ID",
));
}

let notices_dir = base_dir.join("notices");
fs::create_dir_all(&notices_dir)?;

match OpenOptions::new()
.write(true)
.create_new(true)
.open(notices_dir.join(id))
{
Ok(_) => Ok(true),
Err(err) if err.kind() == ErrorKind::AlreadyExists => Ok(false),
Err(err) => Err(err),
}
}

pub fn get_last_version_check_timestamp() -> DateTime<Utc> {
let default: DateTime<Utc> = Utc.timestamp_opt(0, 0).unwrap();

Expand Down Expand Up @@ -346,3 +384,65 @@ where
{
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
}

#[cfg(test)]
mod tests {
use std::{fs, sync::Arc, thread};

use tempfile::TempDir;

use super::claim_notice_at;

#[test]
fn notice_can_only_be_claimed_once() {
let temp_dir = TempDir::new().expect("temporary directory should be created");

assert!(claim_notice_at(temp_dir.path(), "storage-beta-v1").unwrap());
assert!(!claim_notice_at(temp_dir.path(), "storage-beta-v1").unwrap());
}

#[test]
fn notice_generations_are_independent() {
let temp_dir = TempDir::new().expect("temporary directory should be created");

assert!(claim_notice_at(temp_dir.path(), "storage-beta-v1").unwrap());
assert!(claim_notice_at(temp_dir.path(), "storage-beta-v2").unwrap());
}

#[test]
fn concurrent_notice_claims_have_one_winner() {
let temp_dir = TempDir::new().expect("temporary directory should be created");
let base_dir = Arc::new(temp_dir.path().to_path_buf());
let handles = (0..8)
.map(|_| {
let base_dir = Arc::clone(&base_dir);
thread::spawn(move || claim_notice_at(&base_dir, "storage-beta-v1").unwrap())
})
.collect::<Vec<_>>();

let winners = handles
.into_iter()
.map(|handle| handle.join().expect("claim thread should finish"))
.filter(|claimed| *claimed)
.count();

assert_eq!(winners, 1);
}

#[test]
fn notice_claim_rejects_invalid_ids() {
let temp_dir = TempDir::new().expect("temporary directory should be created");

assert!(claim_notice_at(temp_dir.path(), "../outside").is_err());
assert!(claim_notice_at(temp_dir.path(), "").is_err());
}

#[test]
fn notice_claim_reports_unavailable_base_directory() {
let temp_dir = TempDir::new().expect("temporary directory should be created");
let base_dir = temp_dir.path().join("config-file");
fs::write(&base_dir, "not a directory").expect("fixture should be written");

assert!(claim_notice_at(&base_dir, "storage-beta-v1").is_err());
}
}
Loading
Loading