Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
317 changes: 317 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
name: Release

on:
pull_request:
paths:
- ".github/workflows/release.yml"
- "Cargo.lock"
- "Cargo.toml"
- "LICENSE"
- "licenses/**"
- "scripts/_lib/age_keygen_artifacts.py"
- "scripts/_lib/release_archive.py"
- "scripts/_lib/release_installer.py"
- "scripts/_lib/sops_artifacts.py"
- "scripts/assemble-release-assets.py"
- "scripts/build-release-archive.py"
- "scripts/fetch-test-age-keygen.py"
- "scripts/fetch-test-sops.py"
- "tests/package_contract.rs"
- "tests/release_installer_contract.rs"
push:
tags:
- "v*.*.*"

permissions:
contents: read

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

defaults:
run:
shell: bash

env:
CARGO_INCREMENTAL: "0"
CARGO_TERM_COLOR: always
PYTHONDONTWRITEBYTECODE: "1"

jobs:
validate:
name: Validate release input
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
version: ${{ steps.release.outputs.version }}
steps:
- name: Check out the release revision
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
fetch-depth: 0
persist-credentials: false

- name: Validate the release identity
id: release
env:
EVENT_NAME: ${{ github.event_name }}
RELEASE_SHA: ${{ github.sha }}
RELEASE_TAG: ${{ github.ref_name }}
run: |
manifest_version="$(cargo metadata --no-deps --format-version 1 --locked \
| python3 -c 'import json,sys; data=json.load(sys.stdin); print(next(package["version"] for package in data["packages"] if package["name"] == "gitveil"))')"
version="$manifest_version"
if [[ "$EVENT_NAME" == "push" ]]; then
if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "release tag must be an exact vMAJOR.MINOR.PATCH value" >&2
exit 1
fi
version="${RELEASE_TAG#v}"
if [[ "$version" != "$manifest_version" ]]; then
echo "release tag $RELEASE_TAG does not match Cargo version $manifest_version" >&2
exit 1
fi
if ! git merge-base --is-ancestor "$RELEASE_SHA" refs/remotes/origin/main; then
echo "release tag must point to a commit on origin/main" >&2
exit 1
fi
fi
echo "version=$version" >> "$GITHUB_OUTPUT"

build:
name: Build ${{ matrix.system }}-${{ matrix.machine }}
needs: validate
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
system: linux
machine: x86_64
uname_system: Linux
uname_machine: x86_64
- runner: ubuntu-24.04-arm
system: linux
machine: aarch64
uname_system: Linux
uname_machine: aarch64
- runner: macos-15-intel
system: darwin
machine: x86_64
uname_system: Darwin
uname_machine: x86_64
- runner: macos-15
system: darwin
machine: aarch64
uname_system: Darwin
uname_machine: arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
permissions:
contents: read
steps:
- name: Check out the release revision
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false

- name: Verify the native runner
env:
EXPECTED_MACHINE: ${{ matrix.uname_machine }}
EXPECTED_SYSTEM: ${{ matrix.uname_system }}
run: |
[[ "$(uname -s)" == "$EXPECTED_SYSTEM" ]]
[[ "$(uname -m)" == "$EXPECTED_MACHINE" ]]
rustc --version
cargo --version

- name: Install the pinned test runner
uses: taiki-e/install-action@6cd13508893c0e7eab5f273c2575d3859bd7229a # v2
with:
tool: cargo-nextest@0.9.140
checksum: true
fallback: none

- name: Fetch checksum-pinned test sidecars
run: |
sops_bin="$(./scripts/fetch-test-sops.py)"
age_keygen_bin="$(./scripts/fetch-test-age-keygen.py)"
age_keygen_archive="$(./scripts/fetch-test-age-keygen.py --print-archive)"
{
echo "SOPS_BIN=$sops_bin"
echo "AGE_KEYGEN_BIN=$age_keygen_bin"
echo "AGE_KEYGEN_ARCHIVE=$age_keygen_archive"
} >> "$GITHUB_ENV"

- name: Run native host tests
run: cargo nextest run --profile ci-host --all-features --locked --no-tests fail

- name: Build the native release binary
run: cargo build --release --locked

- name: Build the complete native archive
env:
EXPECTED_ARCHIVE: gitveil-v${{ needs.validate.outputs.version }}-${{ matrix.system }}-${{ matrix.machine }}.tar.gz
run: |
mkdir release-assets
./scripts/build-release-archive.py \
--skip-build \
--binary target/release/gitveil \
--sops-bin "$SOPS_BIN" \
--age-keygen-bin "$AGE_KEYGEN_BIN" \
--age-keygen-archive "$AGE_KEYGEN_ARCHIVE" \
--output-dir release-assets
test -f "release-assets/$EXPECTED_ARCHIVE"
test -f "release-assets/$EXPECTED_ARCHIVE.sha256"
echo "ARCHIVE=release-assets/$EXPECTED_ARCHIVE" >> "$GITHUB_ENV"
echo "ARCHIVE_ROOT=${EXPECTED_ARCHIVE%.tar.gz}" >> "$GITHUB_ENV"

- name: Verify the archive checksum and executables
run: |
if command -v sha256sum >/dev/null 2>&1; then
(cd release-assets && sha256sum --check "$(basename "$ARCHIVE").sha256")
else
(cd release-assets && shasum -a 256 --check "$(basename "$ARCHIVE").sha256")
fi
mkdir release-smoke
tar -xzf "$ARCHIVE" -C release-smoke
"release-smoke/$ARCHIVE_ROOT/bin/gitveil" --version
"release-smoke/$ARCHIVE_ROOT/libexec/gitveil/sops" --disable-version-check --version
"release-smoke/$ARCHIVE_ROOT/libexec/gitveil/age-keygen" --version

- name: Upload the native archive
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-${{ matrix.system }}-${{ matrix.machine }}
path: |
${{ env.ARCHIVE }}
${{ env.ARCHIVE }}.sha256
if-no-files-found: error
retention-days: 7

assemble:
name: Assemble release assets
needs:
- validate
- build
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Check out the release revision
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false

- name: Download all native archives
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: release-*
path: release-assets
merge-multiple: true

- name: Verify native archive transfer checksums
run: |
mapfile -t archives < <(find release-assets -maxdepth 1 -type f -name 'gitveil-v*.tar.gz' | sort)
mapfile -t checksums < <(find release-assets -maxdepth 1 -type f -name 'gitveil-v*.tar.gz.sha256' | sort)
[[ "${#archives[@]}" -eq 4 ]]
[[ "${#checksums[@]}" -eq 4 ]]
for checksum in "${checksums[@]}"; do
(cd release-assets && sha256sum --check "$(basename "$checksum")")
done
rm -- "${checksums[@]}"

- name: Build the version-pinned installer and manifest
run: |
./scripts/assemble-release-assets.py \
--version "${{ needs.validate.outputs.version }}" \
--archive-dir release-assets \
--output-dir release-assets
sh -n release-assets/gitveil-installer.sh
(cd release-assets && sha256sum --check SHA256SUMS)

- name: Upload the assembled release
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: assembled-release
path: release-assets/
if-no-files-found: error
retention-days: 7

attest:
name: Attest release assets
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
needs:
- validate
- assemble
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write
attestations: write
steps:
- name: Download the assembled release
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: assembled-release
path: release-assets

- name: Attest every final release asset
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
with:
subject-path: release-assets/*

draft:
name: Create draft release
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
needs:
- validate
- assemble
- attest
runs-on: ubuntu-latest
timeout-minutes: 15
environment:
name: release
url: ${{ steps.release.outputs.url }}
permissions:
contents: write
steps:
- name: Download the assembled release
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: assembled-release
path: release-assets

- name: Verify the final release assets
run: |
[[ "$(find release-assets -maxdepth 1 -type f | wc -l | tr -d ' ')" -eq 6 ]]
(cd release-assets && sha256sum --check SHA256SUMS)

- name: Create or update the draft release
id: release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.ref_name }}
run: |
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
is_draft="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)"
[[ "$is_draft" == "true" ]] || {
echo "refusing to replace assets on a published release" >&2
exit 1
}
gh release upload "$RELEASE_TAG" release-assets/* \
--repo "$GITHUB_REPOSITORY" \
--clobber
else
gh release create "$RELEASE_TAG" release-assets/* \
--repo "$GITHUB_REPOSITORY" \
--verify-tag \
--draft \
--generate-notes \
--title "Gitveil $RELEASE_TAG"
fi
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json url --jq '"url=" + .url' \
>> "$GITHUB_OUTPUT"
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Inspect the existing implementation and tests before proposing changes. Do not i
- The Rust version is pinned in `rust-toolchain.toml`.
- Compatible SOPS and age-keygen builds and artifact checksums are declared in `scripts/_lib/sops_artifacts.py` and `scripts/_lib/age_keygen_artifacts.py`.
- Files directly under `scripts/` are executable entry points; import-only Python modules belong under `scripts/_lib/` and must not be executable.
- GitHub Releases is the only authoritative distribution source. Release installers are immutable version assets with embedded native-archive checksums; never install from a moving branch or publish an incomplete binary-only distribution.
- `Cargo.toml` intentionally has `publish = false`; do not publish the crate or create releases unless explicitly requested.
- Keep all tracked text, code comments, tests, commit messages, and pull request content in English.
- Do not add private keys, plaintext secrets, local absolute paths, generated build output, unpublished planning material, or tool transcripts.
Expand Down Expand Up @@ -96,6 +97,7 @@ Do not change test expectations merely to make a gate pass. Fix the implementati
- Update `README.md` when commands, configuration, installation, security boundaries, or release layout change.
- Update `docs/getting-started.md` when the first-use path changes.
- Update `CONTRIBUTING.md` when contributor tooling or quality gates change.
- Update `docs/releasing.md` when release targets, assets, permissions, attestations, or publication controls change.
- Keep relative links in public Markdown valid; `scripts/check-doc-links.py` enforces them.
- Keep dependency changes intentional and locked. Explain why a new runtime dependency belongs at its architectural boundary.
- Preserve third-party license files and notices in release archives.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Complete validation runs only in GitHub Actions. `.config/nextest.toml` defines
| `Linux` | Ubuntu | Native CLI, Git, SOPS, identity, filesystem, runtime, release build, and package validation |
| `macOS` | macOS | The same native host suite on macOS |

Every pull request and push to `main` runs all three checks on GitHub-hosted runners. CI uses no repository secrets and does not publish artifacts or releases. Do not add aggregate local gate scripts or a Docker replica of the hosted Linux runner.
Every pull request and push to `main` runs all three checks on GitHub-hosted runners. The merge-gate workflow uses no repository secrets and never publishes releases. Release-related pull requests additionally exercise the four-target native archive workflow without requesting attestation or publication permissions. Exact version tags run the trusted attestation and draft-release jobs described in [`docs/releasing.md`](docs/releasing.md). Do not add aggregate local gate scripts or a Docker replica of the hosted Linux runner.

## Pull requests

Expand Down
Loading