name: CI/CD Pipeline
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
workflow_dispatch:
inputs:
release_mode:
description: 'Manual release mode'
required: true
type: choice
default: 'instant'
options:
- instant
- changelog-pr
bump_type:
description: 'Version bump type'
required: true
type: choice
options:
- patch
- minor
- major
description:
description: 'Release description (optional)'
required: false
type: string
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_NET_RETRY: '10'
CARGO_HTTP_MULTIPLEXING: 'false'
GIT_CONFIG_COUNT: '1'
GIT_CONFIG_KEY_0: init.defaultBranch
GIT_CONFIG_VALUE_0: main
RUSTFLAGS: -Dwarnings
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }}
DOCKERHUB_IMAGE: konard/link-assistant-router
jobs:
detect-changes:
name: Detect Changes
runs-on: ubuntu-latest
timeout-minutes: 15
if: github.event_name != 'workflow_dispatch'
outputs:
rs-changed: ${{ steps.changes.outputs.rs-changed }}
toml-changed: ${{ steps.changes.outputs.toml-changed }}
docs-changed: ${{ steps.changes.outputs.docs-changed }}
workflow-changed: ${{ steps.changes.outputs.workflow-changed }}
any-code-changed: ${{ steps.changes.outputs.any-code-changed }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
fetch-depth: 0
- name: Setup Rust
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
toolchain: 1.98.1
- name: Install rust-script
run: cargo install rust-script --version 0.36.0 --locked
- name: Detect changes
id: changes
env:
GITHUB_EVENT_NAME: ${{ github.event_name }}
GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: rust-script scripts/detect-code-changes.rs
changelog:
name: Changelog Fragment Check
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [detect-changes]
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
fetch-depth: 0
- name: Setup Rust
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
toolchain: 1.98.1
- name: Install rust-script
run: cargo install rust-script --version 0.36.0 --locked
- name: Check for changelog fragments
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
run: rust-script scripts/check-changelog-fragment.rs
version-check:
name: Version Modification Check
runs-on: ubuntu-latest
timeout-minutes: 15
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
fetch-depth: 0
- name: Setup Rust
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
toolchain: 1.98.1
- name: Install rust-script
run: cargo install rust-script --version 0.36.0 --locked
- name: Check for manual version changes
env:
GITHUB_EVENT_NAME: ${{ github.event_name }}
GITHUB_HEAD_REF: ${{ github.head_ref }}
GITHUB_BASE_REF: ${{ github.base_ref }}
run: rust-script scripts/check-version-modification.rs
tunnel-image-smoke:
name: Tunnel Image Smoke Test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Build tunnel image
run: docker build --file docker/tunnel/Dockerfile --tag router-tunnel:test .
- name: Missing configuration fails closed
shell: bash
run: |
if docker run --rm router-tunnel:test 2>tunnel-error.log; then
echo "tunnel image unexpectedly started without configuration" >&2
exit 1
fi
grep -Fq "TUNNEL_SSH_HOST" tunnel-error.log
- name: Complete configuration reaches autossh
run: >-
docker run --rm
-e TUNNEL_SSH_HOST=far.example
-e TUNNEL_SSH_USER=router
-e TUNNEL_REMOTE_PORT=18080
-e TUNNEL_SSH_KEY=/dev/null
-e TUNNEL_KNOWN_HOSTS=/etc/hosts
-e AUTOSSH_BIN=/bin/echo
router-tunnel:test
lint:
name: Lint and Format Check
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [detect-changes]
if: |
always() && !cancelled() && (
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.any-code-changed == 'true' ||
needs.detect-changes.outputs.docs-changed == 'true' ||
needs.detect-changes.outputs.workflow-changed == 'true'
)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Setup Rust
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
toolchain: 1.98.1
components: rustfmt, clippy
- name: Install rust-script
run: cargo install rust-script --version 0.36.0 --locked
- name: Set up sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba
- name: Route compilation through sccache
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
- name: Cache cargo registry
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Check formatting
run: cargo fmt --all -- --check
- name: Verify Cargo.lock is current
run: cargo check --locked --all-targets --all-features
- name: Run Clippy
run: cargo clippy --locked --all-targets --all-features
- name: Build documentation
env:
RUSTDOCFLAGS: -D warnings
run: cargo doc --locked --no-deps --all-features
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with:
node-version: '24'
cache: npm
cache-dependency-path: ui/package-lock.json
- name: Install admin console dependencies
working-directory: ui
run: npm ci
- name: Build admin console
working-directory: ui
shell: bash
run: |
set -o pipefail
npm run build 2>&1 | tee /tmp/admin-ui-build.log
if grep -E '(^|[[:space:]])\(!\)|[Ww]arn(ing)?' /tmp/admin-ui-build.log; then
echo "::error::Admin console build emitted a warning"
exit 1
fi
- name: Verify embedded admin console bundle is current
run: git diff --exit-code -- ui/dist
- name: Check file size limit
run: rust-script scripts/check-file-size.rs
- name: Check terminology
run: rust-script scripts/check-terminology.rs
- name: Test release automation
run: |
rust-script --test scripts/version-and-commit.rs
rust-script --test scripts/check-docker-platforms.rs
rust-script --test scripts/check-github-releases.rs
rust-script --test scripts/check-coverage.rs
rust-script --test scripts/detect-code-changes.rs
rust-script --test scripts/check-changelog-fragment.rs
rust-script --test scripts/check-release-provenance.rs
rust-script --test scripts/check-terminology.rs
rust-script --test scripts/create-github-release.rs
rust-script scripts/check-release-workflow.rs
audit:
name: Dependency Audit
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [detect-changes]
if: |
always() && !cancelled() && (
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.any-code-changed == 'true' ||
needs.detect-changes.outputs.workflow-changed == 'true'
)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Setup Rust
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
toolchain: 1.98.1
- name: Set up sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba
- name: Route compilation through sccache
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
- name: Cache cargo registry
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with:
path: |
~/.cargo/registry
~/.cargo/git
~/.cargo/bin
key: ${{ runner.os }}-cargo-audit-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-audit-
- name: Install cargo-audit
run: cargo install cargo-audit --version 0.22.2 --locked --force
- name: Audit Rust dependencies
run: cargo audit
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with:
node-version: '24'
- name: Audit admin console dependencies
working-directory: ui
run: npm audit --audit-level=high
test:
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 45
needs: [detect-changes, changelog]
if: always() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changelog.result == 'success' || needs.changelog.result == 'skipped')
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Setup Rust
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
toolchain: 1.98.1
- name: Set up sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba
- name: Route compilation through sccache
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
- name: Cache cargo registry
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Run tests
if: runner.os != 'Windows'
run: cargo test --locked --all-features --verbose
- name: Compile Windows tests
if: runner.os == 'Windows'
run: cargo test --locked --all-features --verbose --no-run
- name: Run Windows library and binary tests
if: runner.os == 'Windows'
run: cargo test --locked --all-features --verbose --lib --bins -- --test-threads=1
- name: Run Windows integration tests with bounded targets
if: runner.os == 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$testFiles = Get-ChildItem tests -File -Filter '*_test.rs' | Sort-Object Name
foreach ($testFile in $testFiles) {
$target = $testFile.BaseName
Write-Host "::group::Windows integration target $target"
$arguments = @(
'test', '--locked', '--all-features', '--verbose',
'--test', $target, '--', '--test-threads=1'
)
$process = Start-Process -FilePath cargo -ArgumentList $arguments -NoNewWindow -PassThru
if (-not $process.WaitForExit(180000)) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
Write-Host '::endgroup::'
throw "Windows integration target $target exceeded 180 seconds"
}
$exitCode = $process.ExitCode
Write-Host '::endgroup::'
if ($exitCode -ne 0) {
exit $exitCode
}
}
- name: Run doc tests
run: cargo test --locked --doc --verbose
coverage:
name: Test Coverage
runs-on: ubuntu-latest
timeout-minutes: 45
needs: [detect-changes, changelog]
if: always() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changelog.result == 'success' || needs.changelog.result == 'skipped')
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
fetch-depth: 0
- name: Setup Rust
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
toolchain: 1.98.1
components: llvm-tools-preview
- name: Cache coverage tools
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with:
path: |
~/.cargo/registry
~/.cargo/git
~/.cargo/bin
~/.cargo/.crates.toml
~/.cargo/.crates2.json
key: ${{ runner.os }}-cargo-coverage-v2-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-coverage-v2-
- name: Install coverage tools
run: |
if ! cargo llvm-cov --version 2>/dev/null | grep -Fqx 'cargo-llvm-cov 0.9.0'; then
cargo install cargo-llvm-cov --version 0.9.0 --locked --force
fi
command -v rust-script >/dev/null || cargo install rust-script --version 0.36.0 --locked
- name: Test coverage gate behavior
run: rust-script --test scripts/check-coverage.rs
- name: Run instrumented tests
run: |
cargo llvm-cov clean --workspace
cargo llvm-cov --locked --all-features --workspace --no-report
- name: Generate coverage reports
run: |
cargo llvm-cov report --json --summary-only --output-path coverage-summary.json
cargo llvm-cov report --lcov --output-path lcov.info
- name: Read default-branch baseline
id: base-coverage
shell: bash
env:
BASE_REF: ${{ github.base_ref }}
run: |
if [ -n "$BASE_REF" ] && git show "origin/${BASE_REF}:coverage-baseline.txt" > /tmp/base-coverage-baseline.txt 2>/dev/null; then
echo "argument=--previous-baseline /tmp/base-coverage-baseline.txt" >> "$GITHUB_OUTPUT"
fi
- name: Enforce coverage floor and ratchet
shell: bash
env:
BASELINE_ARGUMENT: ${{ steps.base-coverage.outputs.argument }}
COVERAGE_EXCEPTION: ${{ contains(github.event.pull_request.labels.*.name, 'coverage-exception') }}
run: |
args=(--report coverage-summary.json --baseline coverage-baseline.txt --summary "$GITHUB_STEP_SUMMARY")
if [ -n "$BASELINE_ARGUMENT" ]; then
args+=(--previous-baseline /tmp/base-coverage-baseline.txt)
fi
if [ "$COVERAGE_EXCEPTION" = "true" ]; then
args+=(--allow-decrease)
fi
rust-script scripts/check-coverage.rs "${args[@]}"
- name: Require reviewable baseline update
shell: bash
run: |
if ! git diff --exit-code -- coverage-baseline.txt; then
echo "::error::Coverage changed the baseline. Commit coverage-baseline.txt so the ratchet update is reviewable."
exit 1
fi
- name: Upload coverage report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: rust-lcov
path: lcov.info
if-no-files-found: error
build:
name: Build Package
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [lint, test, coverage]
if: always() && !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' && needs.coverage.result == 'success'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Setup Rust
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
toolchain: 1.98.1
- name: Set up sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba
- name: Route compilation through sccache
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
- name: Cache cargo registry
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-build-
- name: Build release
run: cargo build --locked --release --verbose
- name: Check package
run: cargo package --locked --list
auto-release:
name: Auto Release
needs: [lint, test, build]
outputs:
should-release: ${{ steps.check.outputs.should_release }}
release-version: ${{ steps.current_version.outputs.version }}
if: |
always() && !cancelled() &&
github.event_name == 'push' &&
github.ref == 'refs/heads/main' &&
needs.build.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
toolchain: 1.98.1
- name: Install rust-script
run: cargo install rust-script --version 0.36.0 --locked
- name: Configure git
run: rust-script scripts/git-config.rs
- name: Determine bump type from changelog fragments
id: bump_type
run: rust-script scripts/get-bump-type.rs
- name: Check if version already released or no fragments
id: check
env:
HAS_FRAGMENTS: ${{ steps.bump_type.outputs.has_fragments }}
run: rust-script scripts/check-release-needed.rs
- name: Collect changelog and bump version
id: version
if: steps.check.outputs.should_release == 'true' && steps.check.outputs.skip_bump != 'true'
run: |
rust-script scripts/version-and-commit.rs \
--bump-type "${{ steps.bump_type.outputs.bump_type }}"
- name: Get current version
id: current_version
if: steps.check.outputs.should_release == 'true'
run: rust-script scripts/get-version.rs
- name: Build release
if: steps.check.outputs.should_release == 'true'
run: cargo build --locked --release
- name: Publish to Crates.io
if: steps.check.outputs.should_release == 'true'
id: publish-crate
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }}
run: rust-script scripts/publish-crate.rs
- name: Wait for Crate availability on Crates.io
if: steps.check.outputs.should_release == 'true'
run: rust-script scripts/wait-for-crate.rs --release-version "${{ steps.current_version.outputs.version }}"
manual-release:
name: Instant Release
needs: [lint, test, build]
outputs:
should-release: ${{ steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' }}
release-version: ${{ steps.version.outputs.new_version }}
if: |
always() && !cancelled() &&
github.event_name == 'workflow_dispatch' &&
github.event.inputs.release_mode == 'instant' &&
needs.build.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
toolchain: 1.98.1
- name: Install rust-script
run: cargo install rust-script --version 0.36.0 --locked
- name: Configure git
run: rust-script scripts/git-config.rs
- name: Collect changelog fragments
run: rust-script scripts/collect-changelog.rs
- name: Version and commit
id: version
env:
BUMP_TYPE: ${{ github.event.inputs.bump_type }}
DESCRIPTION: ${{ github.event.inputs.description }}
run: rust-script scripts/version-and-commit.rs --bump-type "${{ github.event.inputs.bump_type }}" --description "${{ github.event.inputs.description }}"
- name: Build release
if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
run: cargo build --locked --release
- name: Publish to Crates.io
if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
id: publish-crate
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }}
run: rust-script scripts/publish-crate.rs
- name: Wait for Crate availability on Crates.io
if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
run: rust-script scripts/wait-for-crate.rs --release-version "${{ steps.version.outputs.new_version }}"
publish-docker-images:
name: Build Docker Image (${{ matrix.variant }} / ${{ matrix.arch }})
needs: [create-github-release]
if: always() && !cancelled() && needs.create-github-release.result == 'success'
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-latest
variant: runtime
target: runtime
- arch: arm64
platform: linux/arm64
runner: ubuntu-24.04-arm
variant: runtime
target: runtime
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
permissions:
contents: read
packages: write
id-token: write
attestations: write
artifact-metadata: write
env:
RELEASE_VERSION: ${{ needs.create-github-release.outputs.release-version }}
RELEASE_COMMIT: ${{ needs.create-github-release.outputs.release-commit }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
ref: ${{ needs.create-github-release.outputs.release-commit }}
- name: Verify the workspace is the release tag commit
shell: bash
run: test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
- name: Log in to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f with:
username: konard
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e
- name: Extract Docker metadata
id: docker-meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 with:
images: |
ghcr.io/${{ github.repository }}
${{ env.DOCKERHUB_IMAGE }}
tags: type=raw,value=${{ env.RELEASE_VERSION }}
labels: |
org.opencontainers.image.version=${{ env.RELEASE_VERSION }}
org.opencontainers.image.source=https://github.com/link-assistant/router
- name: Pin the image revision to the release tag commit
id: image-labels
shell: bash
env:
GENERATED_LABELS: ${{ steps.docker-meta.outputs.labels }}
run: |
{
echo 'labels<<LABELS_EOF'
grep -v '^org.opencontainers.image.revision=' <<<"$GENERATED_LABELS"
echo "org.opencontainers.image.revision=${RELEASE_COMMIT}"
echo 'LABELS_EOF'
} >> "$GITHUB_OUTPUT"
- name: Build and push image by digest
id: build-image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a with:
context: .
target: ${{ matrix.target }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.image-labels.outputs.labels }}
outputs: type=image,"name=ghcr.io/${{ github.repository }},${{ env.DOCKERHUB_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.arch }}-${{ matrix.variant }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}-${{ matrix.variant }}
provenance: mode=max
sbom: true
- name: Verify image labels record the release tag commit
env:
IMAGE_DIGEST: ${{ steps.build-image.outputs.digest }}
shell: bash
run: |
labels="$(docker buildx imagetools inspect \
"ghcr.io/${{ github.repository }}@${IMAGE_DIGEST}" --format '{{json .Image}}' | jq '.config.Labels')"
echo "$labels"
test "$(jq -r '."org.opencontainers.image.revision"' <<<"$labels")" = "$RELEASE_COMMIT"
test "$(jq -r '."org.opencontainers.image.version"' <<<"$labels")" = "$RELEASE_VERSION"
- name: Attest GHCR image provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.build-image.outputs.digest }}
push-to-registry: true
- name: Verify published image attestation
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh attestation verify "oci://ghcr.io/${{ github.repository }}@${{ steps.build-image.outputs.digest }}" --repo "${{ github.repository }}"
- name: Export image digest
env:
IMAGE_DIGEST: ${{ steps.build-image.outputs.digest }}
run: |
mkdir -p "/tmp/digests/${{ matrix.variant }}"
touch "/tmp/digests/${{ matrix.variant }}/${IMAGE_DIGEST#sha256:}"
- name: Upload image digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: ${{ matrix.variant }}-${{ matrix.arch }}
path: /tmp/digests/${{ matrix.variant }}/*
if-no-files-found: error
retention-days: 1
publish-docker-manifests:
name: Publish Multi-Platform Docker Manifests
needs: [create-github-release, publish-docker-images]
if: always() && !cancelled() && needs.publish-docker-images.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: write
env:
RELEASE_VERSION: ${{ needs.create-github-release.outputs.release-version }}
RELEASE_COMMIT: ${{ needs.create-github-release.outputs.release-commit }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
ref: ${{ needs.create-github-release.outputs.release-commit }}
fetch-depth: 0
- name: Verify the workspace is the release tag commit
shell: bash
run: test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
- name: Setup Rust
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
toolchain: 1.98.1
- name: Install rust-script
run: cargo install rust-script --version 0.36.0 --locked
- name: Log in to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f with:
username: konard
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e
- name: Download runtime digests
env:
NODE_OPTIONS: --no-deprecation
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
pattern: runtime-*
path: /tmp/digests/runtime
merge-multiple: true
- name: Create multi-platform manifests
shell: bash
run: |
ghcr_runtime=()
dockerhub_runtime=()
for digest in /tmp/digests/runtime/*; do
hash="$(basename "$digest")"
ghcr_runtime+=("ghcr.io/${{ github.repository }}@sha256:$hash")
dockerhub_runtime+=("${{ env.DOCKERHUB_IMAGE }}@sha256:$hash")
done
docker buildx imagetools create \
-t "ghcr.io/${{ github.repository }}:latest" \
-t "ghcr.io/${{ github.repository }}:${RELEASE_VERSION}" \
"${ghcr_runtime[@]}"
docker buildx imagetools create \
-t "${{ env.DOCKERHUB_IMAGE }}:latest" \
-t "${{ env.DOCKERHUB_IMAGE }}:${RELEASE_VERSION}" \
"${dockerhub_runtime[@]}"
- name: Verify GHCR package is publicly pullable
env:
GHCR_IMAGE: ghcr.io/link-assistant/router
run: bash scripts/verify-ghcr-visibility.sh
- name: Verify multi-platform Docker manifests
run: |
rust-script scripts/check-docker-platforms.rs \
"ghcr.io/${{ github.repository }}:latest" \
"ghcr.io/${{ github.repository }}:${{ env.RELEASE_VERSION }}" \
"${{ env.DOCKERHUB_IMAGE }}:latest" \
"${{ env.DOCKERHUB_IMAGE }}:${{ env.RELEASE_VERSION }}"
- name: Verify published image provenance
run: |
rust-script scripts/check-release-provenance.rs \
--release-version "${RELEASE_VERSION}" \
--expected-commit "${RELEASE_COMMIT}" \
--repository "${{ github.repository }}" \
--image "ghcr.io/${{ github.repository }}:${RELEASE_VERSION}" \
--image "ghcr.io/${{ github.repository }}:latest" \
--image "${{ env.DOCKERHUB_IMAGE }}:${RELEASE_VERSION}" \
--image "${{ env.DOCKERHUB_IMAGE }}:latest"
create-github-release:
name: Create GitHub Release
needs: [auto-release, manual-release]
if: |
always() && !cancelled() && (
(needs.auto-release.result == 'success' && needs.auto-release.outputs.should-release == 'true') ||
(needs.manual-release.result == 'success' && needs.manual-release.outputs.should-release == 'true')
)
outputs:
release-version: ${{ needs.auto-release.outputs.release-version || needs.manual-release.outputs.release-version }}
release-commit: ${{ steps.tag-commit.outputs.commit }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
env:
RELEASE_VERSION: ${{ needs.auto-release.outputs.release-version || needs.manual-release.outputs.release-version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
ref: refs/tags/v${{ env.RELEASE_VERSION }}
fetch-depth: 0
- name: Resolve release tag commit
id: tag-commit
shell: bash
run: |
git fetch --force --tags --quiet
commit="$(git rev-parse "refs/tags/v${RELEASE_VERSION}^{commit}")"
if [ "$commit" != "$(git rev-parse HEAD)" ]; then
echo "::error::v${RELEASE_VERSION} resolves to $commit but the workspace is $(git rev-parse HEAD)"
exit 1
fi
echo "commit=$commit" >> "$GITHUB_OUTPUT"
echo "v${RELEASE_VERSION} -> $commit" >> "$GITHUB_STEP_SUMMARY"
- name: Setup Rust
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
toolchain: 1.98.1
- name: Install rust-script
run: cargo install rust-script --version 0.36.0 --locked
- name: Report pre-existing release drift
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: rust-script scripts/check-github-releases.rs --repository "${{ github.repository }}" --default-branch main --historical-orphans warn
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: rust-script scripts/create-github-release.rs --release-version "${{ env.RELEASE_VERSION }}" --repository "${{ github.repository }}" --crates-io-url "https://crates.io/crates/link-assistant-router" --docker-hub-url "https://hub.docker.com/r/konard/link-assistant-router"
- name: Verify GitHub Releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: rust-script scripts/check-github-releases.rs --repository "${{ github.repository }}" --default-branch main --release-version "${{ env.RELEASE_VERSION }}" --historical-orphans warn
publish-release-artifacts:
name: Publish attested binaries (${{ matrix.os }}-${{ matrix.arch }})
needs: [create-github-release]
if: always() && !cancelled() && needs.create-github-release.result == 'success'
strategy:
fail-fast: false
matrix:
include:
- os: linux
arch: amd64
runner: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: linux
arch: arm64
runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
- os: darwin
arch: arm64
runner: macos-latest
target: aarch64-apple-darwin
- os: darwin
arch: amd64
runner: macos-latest
target: x86_64-apple-darwin
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
permissions:
contents: write
id-token: write
attestations: write
env:
RELEASE_VERSION: ${{ needs.create-github-release.outputs.release-version }}
RELEASE_COMMIT: ${{ needs.create-github-release.outputs.release-commit }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
ref: ${{ needs.create-github-release.outputs.release-commit }}
fetch-depth: 0
- name: Setup Rust
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
toolchain: 1.98.1
targets: ${{ matrix.target }}
- name: Verify tag and package version
shell: bash
run: |
package_version="$(cargo metadata --locked --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "link-assistant-router") | .version')"
test "$package_version" = "$RELEASE_VERSION"
test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
test "$(git rev-parse "v${RELEASE_VERSION}^{commit}")" = "$RELEASE_COMMIT"
- name: Build release binaries
run: cargo build --locked --release --bins --target ${{ matrix.target }}
- name: Install pinned CycloneDX generator
run: cargo install cargo-cyclonedx --version 0.5.9 --locked
- name: Package binaries, checksums, and CycloneDX SBOM
shell: bash
env:
PLATFORM: ${{ matrix.os }}-${{ matrix.arch }}
run: |
mkdir -p dist/package
# `router` is the canonical command (issue #222); the other two names
# ship beside it so existing scripts and deployment units keep working.
# A name missing here is absent from every published archive, which is
# how `router` shipped in v0.92.0 without reaching anyone who installs
# from a release rather than with `cargo install`.
cp "target/${{ matrix.target }}/release/router" \
"target/${{ matrix.target }}/release/link-assistant-router" \
"target/${{ matrix.target }}/release/with-router" dist/package/
cp LICENSE README.md dist/package/
cargo cyclonedx --format json --all-features --all --spec-version 1.5 \
--override-filename link-assistant-router.cdx
mv link-assistant-router.cdx.json \
"dist/link-assistant-router-${RELEASE_VERSION}-${PLATFORM}.cdx.json"
jq -e '.bomFormat == "CycloneDX" and (.components | length > 1) and (.dependencies | length > 0)' \
"dist/link-assistant-router-${RELEASE_VERSION}-${PLATFORM}.cdx.json" >/dev/null
tar -C dist/package -czf "dist/link-assistant-router-${RELEASE_VERSION}-${PLATFORM}.tar.gz" .
rm -rf dist/package
# macOS ships `shasum` instead of GNU coreutils' `sha256sum`; both
# emit the same "<digest> <path>" lines consumers verify against.
# Digest the assets from inside dist/ so the recorded names are the flat
# names `gh release download` writes; a `dist/` prefix makes a consumer's
# `sha256sum -c` fail with "No such file or directory".
(
cd dist
if command -v sha256sum >/dev/null 2>&1; then
sha256sum *.tar.gz *.cdx.json
else
shasum -a 256 *.tar.gz *.cdx.json
fi
) > "dist/link-assistant-router-${RELEASE_VERSION}-${PLATFORM}.sha256"
# Smoke test the published checksum file the way a consumer runs it: flat,
# in the directory the assets were downloaded into.
(
cd dist
if command -v sha256sum >/dev/null 2>&1; then
sha256sum -c "link-assistant-router-${RELEASE_VERSION}-${PLATFORM}.sha256"
else
shasum -a 256 -c "link-assistant-router-${RELEASE_VERSION}-${PLATFORM}.sha256"
fi
)
- name: Attest release artifacts
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 with:
subject-path: dist/*
- name: Upload assets to the GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "v${RELEASE_VERSION}" dist/* --clobber
- name: Verify attestations and release asset identity
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for artifact in dist/*; do
gh attestation verify "$artifact" --repo "${{ github.repository }}"
done
assets="$(gh release view "v${RELEASE_VERSION}" --repo "${{ github.repository }}" --json assets --jq '.assets[].name')"
for expected in \
"link-assistant-router-${RELEASE_VERSION}-${{ matrix.os }}-${{ matrix.arch }}.tar.gz" \
"link-assistant-router-${RELEASE_VERSION}-${{ matrix.os }}-${{ matrix.arch }}.cdx.json" \
"link-assistant-router-${RELEASE_VERSION}-${{ matrix.os }}-${{ matrix.arch }}.sha256"; do
grep -Fqx "$expected" <<<"$assets"
done
verify-release-provenance:
name: Verify published release provenance
needs: [create-github-release, publish-docker-manifests, publish-release-artifacts]
if: |
always() && !cancelled() &&
needs.publish-docker-manifests.result == 'success' &&
needs.publish-release-artifacts.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: read
env:
RELEASE_VERSION: ${{ needs.create-github-release.outputs.release-version }}
RELEASE_COMMIT: ${{ needs.create-github-release.outputs.release-commit }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
fetch-depth: 0
- name: Setup Rust
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
toolchain: 1.98.1
- name: Install rust-script
run: cargo install rust-script --version 0.36.0 --locked
- name: Log in to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f with:
username: konard
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e
- name: Fetch the release tag
run: |
git fetch --force --tags --quiet
git rev-parse "refs/tags/v${RELEASE_VERSION}^{commit}" >/dev/null
git rev-parse "refs/tags/v${RELEASE_VERSION}^{commit}^1" >/dev/null
- name: Download the published release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p published
gh release download "v${RELEASE_VERSION}" --repo "${{ github.repository }}" --dir published --clobber
- name: Verify checksums exactly as a consumer does
shell: bash
run: |
cd published
shopt -s nullglob
checksums=(*.sha256)
if [ "${#checksums[@]}" -eq 0 ]; then
echo "::error::the release published no checksum files"
exit 1
fi
for checksum in "${checksums[@]}"; do
sha256sum -c "$checksum"
done
- name: Verify every artifact points at the release tag commit
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
rust-script scripts/check-release-provenance.rs \
--release-version "${RELEASE_VERSION}" \
--expected-commit "${RELEASE_COMMIT}" \
--repository "${{ github.repository }}" \
--image "ghcr.io/${{ github.repository }}:${RELEASE_VERSION}" \
--image "${{ env.DOCKERHUB_IMAGE }}:${RELEASE_VERSION}" \
--asset-dir published
verify-macos-client-lifecycle:
name: Verify macOS client lifecycle
needs: [create-github-release, publish-release-artifacts]
if: always() && !cancelled() && needs.publish-release-artifacts.result == 'success'
runs-on: macos-latest
timeout-minutes: 30
permissions:
contents: read
env:
RELEASE_VERSION: ${{ needs.create-github-release.outputs.release-version }}
RELEASE_COMMIT: ${{ needs.create-github-release.outputs.release-commit }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
ref: ${{ needs.create-github-release.outputs.release-commit }}
- name: Verify the workspace is the release tag commit
shell: bash
run: test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
- name: Setup Rust
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
toolchain: 1.98.1
- name: Download and verify the published darwin-arm64 asset
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PLATFORM: darwin-arm64
run: |
mkdir -p dist
gh release download "v${RELEASE_VERSION}" --repo "${{ github.repository }}" --dir dist \
--pattern "link-assistant-router-${RELEASE_VERSION}-${PLATFORM}.*"
(cd dist && shasum -a 256 -c "link-assistant-router-${RELEASE_VERSION}-${PLATFORM}.sha256")
mkdir -p dist/package
tar -C dist/package -xzf "dist/link-assistant-router-${RELEASE_VERSION}-${PLATFORM}.tar.gz"
# The shipped binaries must run natively before the lifecycle is
# tried. `router` is checked first: it is the canonical command, and
# asserting it here is what would have caught its absence from the
# v0.92.0 archives (issue #222).
dist/package/router --version
dist/package/link-assistant-router --version
dist/package/with-router --help >/dev/null
- name: Forward the remote router to a localhost port
id: forward
shell: bash
env:
ROUTER_SSH_TARGET: ${{ secrets.ROUTER_SSH_TARGET }}
ROUTER_SSH_KEY: ${{ secrets.ROUTER_SSH_KEY }}
ROUTER_REMOTE_ADDRESS: ${{ secrets.ROUTER_REMOTE_ADDRESS }}
run: |
if [ -z "$ROUTER_SSH_TARGET" ] || [ -z "$ROUTER_SSH_KEY" ] || [ -z "$ROUTER_REMOTE_ADDRESS" ]; then
echo "SSH secrets are absent; skipping the remote lifecycle."
echo "forwarded=false" >> "$GITHUB_OUTPUT"
exit 0
fi
install -m 700 -d ~/.ssh
printf '%s\n' "$ROUTER_SSH_KEY" > ~/.ssh/router_release
chmod 600 ~/.ssh/router_release
ssh-keyscan -H "${ROUTER_SSH_TARGET#*@}" >> ~/.ssh/known_hosts
ssh -i ~/.ssh/router_release -f -N -o ExitOnForwardFailure=yes \
-L "18080:${ROUTER_REMOTE_ADDRESS}" "$ROUTER_SSH_TARGET"
echo "forwarded=true" >> "$GITHUB_OUTPUT"
- name: Run the host CLI lifecycle against the forwarded router
if: steps.forward.outputs.forwarded == 'true'
env:
ROUTER_HOST_CLI_TESTS: "1"
ROUTER_HOST_CLI_URL: http://127.0.0.1:18080
ROUTER_HOST_CLI_TOKEN: ${{ secrets.ROUTER_HOST_CLI_TOKEN }}
run: cargo test --locked --test host_client_lifecycle_test -- --nocapture
changelog-pr:
name: Create Changelog PR
if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changelog-pr'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
fetch-depth: 0
- name: Setup Rust
uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
toolchain: 1.98.1
- name: Install rust-script
run: cargo install rust-script --version 0.36.0 --locked
- name: Create changelog fragment
env:
BUMP_TYPE: ${{ github.event.inputs.bump_type }}
DESCRIPTION: ${{ github.event.inputs.description }}
run: rust-script scripts/create-changelog-fragment.rs --bump-type "${{ github.event.inputs.bump_type }}" --description "${{ github.event.inputs.description }}"
- name: Create Pull Request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore: add changelog for manual ${{ github.event.inputs.bump_type }} release'
branch: changelog-manual-release-${{ github.run_id }}
delete-branch: true
title: 'chore: manual ${{ github.event.inputs.bump_type }} release'
body: |
## Manual Release Request
This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release.
### Release Details
- **Type:** ${{ github.event.inputs.bump_type }}
- **Description:** ${{ github.event.inputs.description || 'Manual release' }}
- **Triggered by:** @${{ github.actor }}
### Next Steps
1. Review the changelog fragment in this PR
2. Merge this PR to main
3. The automated release workflow will publish to crates.io and create a GitHub release