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
114 changes: 87 additions & 27 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
name: Release

on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: Existing annotated release tag (for example, v0.0.1)
required: true
type: string

permissions:
contents: read
Expand All @@ -18,7 +10,7 @@ env:
CARGO_TERM_COLOR: always

concurrency:
group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
group: release-${{ github.repository }}-${{ github.ref_name }}
cancel-in-progress: false

jobs:
Expand All @@ -27,11 +19,12 @@ jobs:
runs-on: ubuntu-24.04
outputs:
tag: ${{ steps.release.outputs.tag }}
commit: ${{ steps.release.outputs.commit }}
steps:
- name: Check out release tag
- name: Check out release commit
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
ref: ${{ github.sha }}
fetch-depth: 0

- name: Install Rust toolchain
Expand All @@ -44,13 +37,40 @@ jobs:
with:
python-version: "3.11"

- name: Verify tag and source tree
- name: Verify authoritative release source
id: release
shell: bash
run: |
python x.py verify-release
if [[ "$GITHUB_REPOSITORY" != "wavefnd/Vex" ]]; then
echo "error: official releases must run in wavefnd/Vex, not $GITHUB_REPOSITORY" >&2
exit 1
fi
if [[ "$GITHUB_REF" != "refs/heads/master" ]]; then
echo "error: official releases must be dispatched from master, not $GITHUB_REF" >&2
exit 1
fi

commit="$(git rev-parse HEAD)"
git fetch --force --tags origin
remote_master="$(git ls-remote origin refs/heads/master | awk '{print $1}')"
if [[ -z "$remote_master" || "$commit" != "$remote_master" ]]; then
echo "error: release commit $commit is not current wavefnd/Vex:master ($remote_master)" >&2
exit 1
fi

version="$(python x.py --version | awk '{print $2}')"
echo "tag=v${version}" >> "$GITHUB_OUTPUT"
tag="v${version}"
if git show-ref --verify --quiet "refs/tags/$tag"; then
tag_type="$(git cat-file -t "refs/tags/$tag")"
tag_commit="$(git rev-list -n 1 "$tag")"
if [[ "$tag_type" != "tag" || "$tag_commit" != "$commit" ]]; then
echo "error: existing $tag is not an annotated tag for release commit $commit" >&2
exit 1
fi
fi

echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "commit=$commit" >> "$GITHUB_OUTPUT"

- name: Run release validation
run: python x.py check
Expand Down Expand Up @@ -88,10 +108,10 @@ jobs:
extension: tar.gz
runs-on: ${{ matrix.os }}
steps:
- name: Check out release tag
- name: Check out release commit
uses: actions/checkout@v7
with:
ref: ${{ needs.validate.outputs.tag }}
ref: ${{ needs.validate.outputs.commit }}
fetch-depth: 0

- name: Install RISC-V system tools
Expand Down Expand Up @@ -127,7 +147,7 @@ jobs:
retention-days: 14

draft-release:
name: Attest and create draft release
name: Tag, attest, and create draft release
needs:
- validate
- build
Expand All @@ -137,10 +157,10 @@ jobs:
contents: write
id-token: write
steps:
- name: Check out release tag
- name: Check out release commit
uses: actions/checkout@v7
with:
ref: ${{ needs.validate.outputs.tag }}
ref: ${{ needs.validate.outputs.commit }}
fetch-depth: 0

- name: Install Python
Expand Down Expand Up @@ -168,6 +188,29 @@ jobs:
cd dist
sha256sum --check SHA256SUMS

- name: Create authoritative annotated release tag
env:
RELEASE_COMMIT: ${{ needs.validate.outputs.commit }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
shell: bash
run: |
git fetch --force --tags origin
if git show-ref --verify --quiet "refs/tags/$RELEASE_TAG"; then
tag_type="$(git cat-file -t "refs/tags/$RELEASE_TAG")"
tag_commit="$(git rev-list -n 1 "$RELEASE_TAG")"
if [[ "$tag_type" != "tag" || "$tag_commit" != "$RELEASE_COMMIT" ]]; then
echo "error: existing $RELEASE_TAG is not an annotated tag for $RELEASE_COMMIT" >&2
exit 1
fi
echo "Reusing existing authoritative tag $RELEASE_TAG"
else
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$RELEASE_TAG" "$RELEASE_COMMIT" -m "Vex $RELEASE_TAG"
git push origin "refs/tags/$RELEASE_TAG"
fi
python x.py verify-release

- name: Generate build provenance attestations
uses: actions/attest@v4
with:
Expand All @@ -182,11 +225,28 @@ jobs:
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
shell: bash
run: |
gh release create "$RELEASE_TAG" \
--verify-tag \
--draft \
--notes-file RELEASE_NOTES.md \
--title "Vex $RELEASE_TAG" \
dist/vex-*.tar.gz \
dist/vex-*.zip \
dist/SHA256SUMS
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
is_draft="$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft)"
if [[ "$is_draft" != "true" ]]; then
echo "error: release $RELEASE_TAG is already published" >&2
exit 1
fi
gh release upload "$RELEASE_TAG" \
--clobber \
dist/vex-*.tar.gz \
dist/vex-*.zip \
dist/SHA256SUMS
gh release edit "$RELEASE_TAG" \
--draft \
--notes-file RELEASE_NOTES.md \
--title "Vex $RELEASE_TAG"
else
gh release create "$RELEASE_TAG" \
--verify-tag \
--draft \
--notes-file RELEASE_NOTES.md \
--title "Vex $RELEASE_TAG" \
dist/vex-*.tar.gz \
dist/vex-*.zip \
dist/SHA256SUMS
fi
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,14 @@ linker to be installed. `VEX_RELEASE_HOST` exists for release infrastructure
that must override host-target detection; normal development should not set it.

`python3 x.py verify-release` checks that the source tree is clean and `HEAD`
has the annotated `v<version>` tag required by the release workflow. The
workflow builds every release target before creating one checksum manifest and
a draft GitHub Release. It also generates GitHub build-provenance attestations;
publishing the reviewed draft remains a separate maintainer action. See
[RELEASING.md](RELEASING.md) for the complete procedure.
has the annotated `v<version>` tag. For an official release, a maintainer
dispatches the Release workflow from `wavefnd/Vex:master`; the workflow refuses
forks and non-`master` refs. It validates and packages the exact upstream
commit, verifies the complete archive set, and only then creates the
authoritative annotated tag in `wavefnd/Vex`. It subsequently verifies that
tag, generates GitHub build-provenance attestations, and prepares a draft
GitHub Release. Publishing the reviewed draft remains a separate maintainer
action. See [RELEASING.md](RELEASING.md) for the complete procedure.

Verify downloaded archives from the directory containing `SHA256SUMS`:

Expand Down
74 changes: 39 additions & 35 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@

This document is the maintainer procedure for producing an official Vex
release. The release workflow builds reproducible archives from an existing
annotated version tag, creates provenance attestations, and prepares a draft
GitHub Release. It never publishes a release automatically.
authoritative `master` commit, creates the version tag in `wavefnd/Vex` only
after every target packages successfully, creates provenance attestations, and
prepares a draft GitHub Release. It never publishes a release automatically.

## Release contract

- `Cargo.toml` is the single source of truth for the Vex version.
- The release tag must be the exact `v<version>` tag, point at the release
commit, and be annotated. A signed annotated tag is preferred.
commit, and be annotated. The upstream Release workflow creates it using the
GitHub Actions identity; maintainers do not push the official tag from a
local checkout or personal fork.
- The release commit and committed `Cargo.lock` must be used without changes.
- Every configured target must build and package successfully before a draft
release is created. Partial releases are not supported.
- Every configured target must build and package successfully, and the complete
archive set must be verified, before the tag or draft release is created.
Partial releases are not supported.
- Release archives and `SHA256SUMS` receive GitHub build-provenance
attestations.
- Publishing the reviewed draft is a separate, intentional maintainer action.
Expand Down Expand Up @@ -51,35 +55,32 @@ checklist before tagging:
7. Merge the release-candidate pull request and wait for every required CI
check on `master` to pass.

Do not create a release tag from a feature branch, a dirty checkout, or a
commit that has not passed the required checks.
Do not create or push the release tag locally. The workflow guard ensures the
official tag is created only in `wavefnd/Vex`, from its current `master`.

## 2. Create and push the version tag
## 2. Dispatch the upstream Release workflow

Fetch the authoritative repository and verify the commit before tagging:
After the release-candidate pull request is merged and the required `master`
checks pass, dispatch `.github/workflows/release.yml` in the authoritative
repository:

```sh
git remote add upstream https://github.com/wavefnd/Vex.git # if not already configured
git fetch upstream
git switch master
git merge --ff-only upstream/master
git status --short --branch
git log -1 --oneline
python3 x.py check
git tag -s v0.0.1 -m "Vex v0.0.1"
python3 x.py verify-release
git push upstream v0.0.1
gh workflow run release.yml --repo wavefnd/Vex --ref master
gh run list --repo wavefnd/Vex --workflow release.yml --limit 1
```

If signed tags are not available in the maintainer environment, `git tag -a`
meets the automation's minimum annotated-tag requirement. Record that exception
in the release notes. Never replace or move a published release tag.
The workflow has no user-supplied tag input. It derives `v<version>` from the
checked-in `Cargo.toml` and records the exact current `wavefnd/Vex:master`
commit. It fails before building if it is dispatched in a fork, from another
branch, or from a stale commit.

Pushing `v*` to `wavefnd/Vex` starts `.github/workflows/release.yml`. Pushing a
tag only to a personal fork does not create the official release. A maintainer
can rerun the same workflow manually with an existing annotated tag; the
workflow still checks that the tag matches `Cargo.toml` and the checked-out
commit.
The platform matrix builds and smoke-tests that exact commit without a tag.
After all targets succeed and the complete archive set passes checksum
verification, the final job creates an annotated tag through the
`wavefnd/Vex` workflow token, verifies it with `python3 x.py verify-release`,
attests the artifacts, and creates the draft release. A retry may reuse only an
existing annotated tag that points to the same release commit; it never moves
or replaces a tag. An already published release also cannot be overwritten.

## 3. Review the draft release

Expand Down Expand Up @@ -130,13 +131,16 @@ project smoke tests using assets downloaded from the public release.

## Failure and recovery

- A failed matrix does not create a GitHub Release. Fix the problem in a new
commit and use a new pre-release version or tag; do not move a public tag.
- If the workflow fails before the draft is created, inspect the failed target,
correct the release commit, and restart the release process with an
appropriate new tag.
- If review finds a problem in an unpublished draft, delete the draft and its
unadvertised tag only after confirming no user depends on it, then prepare a
corrected release commit and tag.
- A failed validation, build, package, or checksum step creates neither a tag
nor a GitHub Release. Fix the problem in a new pull request, merge it, and
dispatch the workflow again.
- If the workflow fails after creating the tag but before creating the draft,
rerun it from the unchanged release commit. It safely reuses only the same
annotated tag. If source changes are required, increment the version; never
move an existing official tag.
- If review finds a problem in an unpublished draft, do not publish it. Remove
the draft and unadvertised tag only after confirming no user depends on them,
then prepare a corrected release commit with a new version and dispatch the
upstream workflow again.
- Never publish a partial set of target archives or hand-edit generated
archives and checksums.
14 changes: 13 additions & 1 deletion tests/xpy/test_release_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ def test_release_requires_version_tag_at_head(self) -> None:
):
release_tool.require_release_tag("0.0.1")

def test_release_workflow_covers_every_supported_target(self) -> None:
def test_release_workflow_covers_targets_and_creates_the_upstream_tag(self) -> None:
workflow = (ROOT / ".github/workflows/release.yml").read_text(
encoding="utf-8"
)
Expand All @@ -259,6 +259,18 @@ def test_release_workflow_covers_every_supported_target(self) -> None:
self.assertIn("uses: actions/attest@v4", workflow)
self.assertIn("--draft", workflow)
self.assertIn("--notes-file RELEASE_NOTES.md", workflow)
self.assertIn("workflow_dispatch:", workflow)
self.assertNotIn(" push:\n", workflow)
self.assertNotIn(" inputs:\n", workflow)
self.assertIn('GITHUB_REPOSITORY" != "wavefnd/Vex', workflow)
self.assertIn('git tag -a "$RELEASE_TAG"', workflow)
self.assertIn('git push origin "refs/tags/$RELEASE_TAG"', workflow)
self.assertIn("ref: ${{ needs.validate.outputs.commit }}", workflow)
self.assertIn("python x.py verify-release", workflow)
self.assertLess(
workflow.index("sha256sum --check SHA256SUMS"),
workflow.index('git tag -a "$RELEASE_TAG"'),
)

@staticmethod
def make_package_inputs(root: Path, target: object, binary: bytes) -> None:
Expand Down