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
92 changes: 92 additions & 0 deletions .github/workflows/create-release-tag.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
name: Create release tag

on:
pull_request:
types: [closed]
branches: [main]

permissions:
contents: read

concurrency:
group: create-release-tag
cancel-in-progress: false

defaults:
run:
shell: bash

jobs:
tag:
name: Tag the merged release
if: |
github.event.pull_request.merged == true &&
startsWith(github.event.pull_request.head.ref, 'release/')
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# The dedicated fine-grained RELEASE_TOKEN (contents read/write on this
# repository only) is required because tags pushed with GITHUB_TOKEN do
# not trigger the tag-driven release workflow.
- name: Check out the merge commit with the tagging credential
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 0
token: ${{ secrets.RELEASE_TOKEN }}

- name: Read and validate the release version
id: version
env:
HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
version="$(python3 -c 'import tomllib; print(tomllib.load(open("Cargo.toml", "rb"))["package"]["version"])')"
if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "Cargo.toml version $version is not exact SemVer" >&2
exit 1
fi
if [[ "$HEAD_BRANCH" != "release/v$version" ]]; then
echo "the release/ branch prefix is reserved for scripts/prepare-release.py (see docs/releasing.md);" >&2
echo "$HEAD_BRANCH does not match the Cargo version $version (expected release/v$version), so no tag was created" >&2
exit 1
fi
if [[ -n "$(git ls-remote origin "refs/tags/v$version")" ]]; then
# An existing tag is only a safe no-op when it already points at
# this merge commit (idempotent re-run); pointing anywhere else
# means the version number is taken and needs a human.
git fetch --quiet --no-tags origin "refs/tags/v$version:refs/tags/v$version"
tagged="$(git rev-parse "v$version^{commit}")"
if [[ "$tagged" == "$(git rev-parse HEAD)" ]]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "tag v$version already points at this merge commit; nothing to do"
else
echo "tag v$version already exists and points at $tagged, not this merge commit;" >&2
echo "the version number is taken: run scripts/prepare-release.py again to bump to the next version" >&2
exit 1
fi
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
echo "version=$version" >> "$GITHUB_OUTPUT"

- name: Create and push the annotated tag
if: steps.version.outputs.exists == 'false'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "v$VERSION" -m "Gitveil v$VERSION"
git push origin "v$VERSION"
echo "created tag v$VERSION at $(git rev-parse HEAD)"

- name: Delete the merged release branch
env:
HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
if [[ -n "$(git ls-remote --heads origin "$HEAD_BRANCH")" ]]; then
git push origin --delete "$HEAD_BRANCH"
echo "deleted branch $HEAD_BRANCH"
else
echo "branch $HEAD_BRANCH is already gone"
fi
61 changes: 57 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -273,9 +273,6 @@ jobs:
- attest
runs-on: ubuntu-latest
timeout-minutes: 15
environment:
name: release
url: ${{ steps.release.outputs.url }}
permissions:
contents: write
steps:
Expand All @@ -291,7 +288,6 @@ jobs:
(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 }}
Expand All @@ -313,5 +309,62 @@ jobs:
--generate-notes \
--title "Gitveil $RELEASE_TAG"
fi

publish:
name: Publish release
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
needs:
- validate
- draft
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: release
url: ${{ steps.publish.outputs.url }}
permissions:
contents: write
steps:
# Draft assets stay mutable until publication, so the facts the
# approval relies on are re-derived here, inside the gate: the six
# assets are downloaded and verified byte-for-byte against SHA256SUMS
# and their build attestations before anything goes live.
- name: Verify the draft release before publication
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.ref_name }}
VERSION: ${{ needs.validate.outputs.version }}
run: |
state="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)"
if [[ "$state" != "true" ]]; then
echo "release $RELEASE_TAG is not a draft; refusing to touch a published release" >&2
exit 1
fi
mkdir release-review
gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --dir release-review
mapfile -t assets < <(cd release-review && find . -maxdepth 1 -type f -exec basename {} \; | sort)
expected=$(printf '%s\n' \
"SHA256SUMS" \
"gitveil-installer.sh" \
"gitveil-v$VERSION-darwin-aarch64.tar.gz" \
"gitveil-v$VERSION-darwin-x86_64.tar.gz" \
"gitveil-v$VERSION-linux-aarch64.tar.gz" \
"gitveil-v$VERSION-linux-x86_64.tar.gz" | sort)
if [[ "$(printf '%s\n' "${assets[@]}")" != "$expected" ]]; then
echo "draft assets do not match the six-asset release contract" >&2
printf 'actual:\n%s\n' "${assets[@]}" >&2
exit 1
fi
(cd release-review && sha256sum --check SHA256SUMS)
for asset in release-review/*; do
gh attestation verify "$asset" --repo "$GITHUB_REPOSITORY"
done

- name: Publish the release
id: publish
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.ref_name }}
run: |
gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json url --jq '"url=" + .url' \
>> "$GITHUB_OUTPUT"
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ GitHub Actions installs the pinned `cargo-nextest` and `cargo-deny` versions use

## Making changes

Create a branch from the latest `main`. Keep commits focused and use English for commit messages, pull request titles, descriptions, code comments, tests, and documentation.
Create a branch from the latest `main`. Keep commits focused and use English for commit messages, pull request titles, descriptions, code comments, tests, and documentation. Branch names starting with `release/` are reserved for `scripts/prepare-release.py`: merging such a branch triggers the automated release tagging described in [`docs/releasing.md`](docs/releasing.md), so never use that prefix for ordinary work.

When changing core behavior, add or update the relevant test first and confirm that it fails for the expected reason before implementing the change. Pure decisions belong in pure modules; filesystem, process, Git, SOPS, environment, clock, and random effects stay at explicit boundaries.

Expand Down 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. 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.
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. Merging a `release/v*` pull request tags the merge commit, and the exact version tag runs the trusted attestation, draft, and approval-gated publish 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
51 changes: 28 additions & 23 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,28 +22,32 @@ Published releases are immutable. Their tag and assets cannot be replaced or del
Repository settings enforce the publication boundary:

- the `Protect release tags` ruleset prevents updates and deletion for `refs/tags/v*`;
- the `release` environment accepts only `v*` tags and requires maintainer approval before the draft job receives write permission; and
- GitHub release immutability locks the tag and all assets when the draft is published.
- the `release` environment accepts only `v*` tags and requires maintainer approval before the publish job receives write permission; and
- GitHub release immutability locks the tag and all assets at publication.

## Prepare the version
A version tag push builds, attests, and drafts the release without human involvement: draft creation and asset upload are not behind the approval gate. Drafts are invisible to users and carry no immutability, so their assets remain mutable until publication. The `release` environment therefore gates publication only, and the publish job re-derives the facts the approval relies on inside the gate: it downloads every draft asset, checks `SHA256SUMS`, and verifies each artifact attestation before anything goes live.

1. Update the package version in `Cargo.toml` through a pull request. Update `Cargo.lock` and user documentation when necessary.
2. Merge only after the required `Core`, `Linux`, and `macOS` checks pass. Release-related pull requests also build all four native target archives through `.github/workflows/release.yml`.
3. Confirm that the release commit is on `origin/main`, the working tree is clean, and the version has not already been released.
## One-time setup

The tag must exactly match the Cargo package version. Prerelease suffixes and moving branch references are rejected.
Automatic tagging uses a dedicated credential because tags pushed with the default `GITHUB_TOKEN` do not trigger the tag-driven release workflow:

## Create the draft release
1. Create a fine-grained personal access token scoped to this repository only, with `Contents: Read and write` and no other permission, and a bounded expiration.
2. Store it as the `RELEASE_TOKEN` repository Actions secret.
3. When it expires, generate a replacement and overwrite the secret; nothing else changes.

From an up-to-date `main` checkout, create and push an annotated tag:
## Standard release

A release is one command and two clicks:

```bash
git switch main
git pull --ff-only origin main
git tag -a v0.1.0 -m "Gitveil v0.1.0"
git push origin v0.1.0
scripts/prepare-release.py patch # or minor / major
```

The script verifies a clean, up-to-date `main`, computes the next version, updates `Cargo.toml`, `Cargo.lock`, and the pinned installer links in `README.md`, and opens a pull request from a `release/v<version>` branch. Because the pull request touches `Cargo.toml`, the release workflow also builds all four native target archives as a pre-release rehearsal.

1. **Merge the release pull request** after the required `Core`, `Linux`, and `macOS` checks and the rehearsal pass. On merge, `create-release-tag.yml` validates that the branch name matches the Cargo version, tags the merge commit `v<version>`, and deletes the release branch. The tag push starts the release workflow.
2. **Approve the `release` environment** in the workflow run once the build, assembly, and attestation jobs finish. Approval is the publication decision: the publish job re-verifies the six-asset contract against the draft and then publishes it. There is no separate manual publish step.

The tag-triggered release workflow:

1. verifies that the tag is exact SemVer, matches `Cargo.toml`, and points to `origin/main`;
Expand All @@ -53,32 +57,33 @@ The tag-triggered release workflow:
5. transfers all four archives with per-archive checksums;
6. generates the version-pinned installer and aggregate `SHA256SUMS` manifest;
7. attests all six final assets;
8. waits for approval of the `release` environment; and
9. creates or updates a draft GitHub Release with generated release notes.
8. creates a draft release with generated release notes;
9. waits for approval of the `release` environment; and
10. downloads the draft assets, verifies the six-asset contract, `SHA256SUMS`, and every artifact attestation, and publishes the release.

The workflow refuses to replace assets on a published release.
The workflow refuses to touch a release that is already published.

## Verify and publish
## Inspecting before approval

After the workflow succeeds, inspect the draft release before publishing it:
The draft and its generated notes are visible on the Releases page before the environment is approved. For an independent check on a trusted machine, download the draft assets and verify them:

```bash
rm -rf release-review
mkdir release-review
gh release download v0.1.0 --repo Sukitly/gitveil --dir release-review
(cd release-review && sha256sum --check SHA256SUMS)
gh release download v<version> --repo Sukitly/gitveil --dir release-review
(cd release-review && shasum -a 256 --check SHA256SUMS)
for asset in release-review/*; do
gh attestation verify "$asset" --repo Sukitly/gitveil
done
```

On macOS, use `shasum -a 256 --check SHA256SUMS` when `sha256sum` is unavailable. Confirm that the draft contains exactly the six declared assets, all attestations verify, generated notes describe the intended changes, and every release workflow job has no unresolved annotation.

Publish the draft through the GitHub Releases interface. Publication makes the release and its assets immutable and updates the `releases/latest` installer URL.
Release notes remain editable after publication; assets and the tag do not.

## Failed releases

- Retry a failed workflow job when the failure is transient and no artifact input changed.
- Never move or reuse a release tag after artifacts have been published.
- If source, dependencies, packaging, checksums, or installer behavior must change, fix them through a pull request and publish a new patch version.
- If `create-release-tag.yml` fails because `RELEASE_TOKEN` expired, refresh the secret and re-run the job; the tag step is idempotent.
- Manual fallback: the automated path is equivalent to tagging by hand. From an up-to-date `main` checkout of the release commit, `git tag -a v<version> -m "Gitveil v<version>" && git push origin v<version>` starts the same tag-triggered workflow.
- For a security release, coordinate through GitHub Private Vulnerability Reporting and publish the associated security advisory when disclosure is appropriate.
79 changes: 79 additions & 0 deletions scripts/_lib/release_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Pure version arithmetic and document rewriting for release preparation.

The package version is defined as the `version` key inside the `[package]`
section of Cargo.toml. Reads and writes are anchored to that section so this
module agrees with the structured readers in the release workflows
(`tomllib` in create-release-tag.yml, `cargo metadata` in release.yml) even
when other sections carry their own `version` lines.
"""

from __future__ import annotations

import re

_PACKAGE_HEADER_PATTERN = re.compile(r"^\[package\][ \t]*$", re.MULTILINE)
_SECTION_HEADER_PATTERN = re.compile(r"^\[", re.MULTILINE)
_VERSION_LINE_PATTERN = re.compile(r'^version = "(\d+)\.(\d+)\.(\d+)"$', re.MULTILINE)
_PINNED_DOWNLOAD_PATTERN = re.compile(r"releases/download/v\d+\.\d+\.\d+/")


class ReleaseVersionError(ValueError):
"""A manifest or document does not carry the expected release shape."""


def _package_section_span(manifest: str) -> tuple[int, int]:
header = _PACKAGE_HEADER_PATTERN.search(manifest)
if header is None:
raise ReleaseVersionError("Cargo.toml carries no [package] section")
start = header.end()
following = _SECTION_HEADER_PATTERN.search(manifest, start)
end = len(manifest) if following is None else following.start()
return start, end


def package_version(manifest: str) -> str:
"""Returns the exact SemVer version of the `[package]` section."""
start, end = _package_section_span(manifest)
match = _VERSION_LINE_PATTERN.search(manifest, start, end)
if match is None:
raise ReleaseVersionError(
"the [package] section carries no exact SemVer version"
)
return ".".join(match.groups())


def bumped_version(current: str, level: str) -> str:
"""Returns `current` bumped by one `major`, `minor`, or `patch` step."""
major, minor, patch = (int(part) for part in current.split("."))
if level == "major":
return f"{major + 1}.0.0"
if level == "minor":
return f"{major}.{minor + 1}.0"
if level == "patch":
return f"{major}.{minor}.{patch + 1}"
raise ReleaseVersionError(f"unknown bump level {level!r}")


def set_package_version(manifest: str, version: str) -> str:
"""Rewrites the `[package]` version; other sections stay untouched."""
start, end = _package_section_span(manifest)
section, count = _VERSION_LINE_PATTERN.subn(
f'version = "{version}"', manifest[start:end], count=1
)
if count != 1:
raise ReleaseVersionError(
"the [package] section carries no exact SemVer version"
)
return manifest[:start] + section + manifest[end:]


def rewrite_pinned_downloads(document: str, version: str) -> str:
"""Repoints `releases/download/vX.Y.Z/` links; `releases/latest` stays."""
rewritten, count = _PINNED_DOWNLOAD_PATTERN.subn(
f"releases/download/v{version}/", document
)
if count == 0:
raise ReleaseVersionError(
"the document carries no pinned release download links"
)
return rewritten
Loading