Release Nexus #82
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release Nexus | |
| # Canonical self-versioning release workflow for the Eidolons nexus CLI. | |
| # | |
| # Divergence from eidolon-release-template.yml (documented here for reviewers): | |
| # - No "Roster Intake" step. The nexus is not ingested into any roster. | |
| # - No manifest_sha256. The nexus has no install.manifest.json (EIIS concept | |
| # that does not apply here). | |
| # - provenance.github_attestation is false for v1.0.0. Adopt | |
| # actions/attest-build-provenance in a follow-up (OQ-3). | |
| # - The release metadata PR targets main via a chore/nexus-release-<v> branch. | |
| # It does NOT push directly to main — same discipline as the eidolon | |
| # roster-bump flow. | |
| # | |
| # Trigger: | |
| # Canonical path: workflow_dispatch with `version` input (e.g. "1.0.0"). | |
| # Fallback path: push to tags matching v*.*.* (for manually-pushed tags). | |
| # | |
| # Tag handling: | |
| # workflow_dispatch — checks out main HEAD; the workflow itself creates and | |
| # pushes the v<version> tag (mirrors the per-Eidolon | |
| # eidolon-release-template.yml pattern). This is the | |
| # canonical maintainer flow: bump VERSION + CHANGELOG | |
| # in a PR, merge, then dispatch Release Nexus. | |
| # push:tags — checks out the tag SHA (already exists by definition); | |
| # the "Create tag" step short-circuits. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: 'Release version (e.g. 1.0.0 — without the leading v)' | |
| required: true | |
| type: string | |
| push: | |
| tags: | |
| - 'v[0-9]+.[0-9]+.[0-9]+' | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| jobs: | |
| release: | |
| name: Build and release nexus v${{ github.event.inputs.version || github.ref_name }} | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Resolve version | |
| id: ver | |
| run: | | |
| if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then | |
| VERSION="${{ github.event.inputs.version }}" | |
| # Validate SemVer shape on dispatch input (push-tag path is gated by | |
| # the on.tags glob, so it's already constrained). | |
| if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then | |
| echo "::error::version input must be SemVer without leading v (got '$VERSION')" | |
| exit 1 | |
| fi | |
| else | |
| # Strip leading 'v' from tag ref. | |
| VERSION="${GITHUB_REF_NAME#v}" | |
| fi | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| # workflow_dispatch: default ref (the branch the workflow was | |
| # dispatched from, normally main). The "Create tag" step below | |
| # tags this commit and pushes v<version>. | |
| # push:tags: GitHub already checks out the tag SHA; we don't need | |
| # to override the ref. | |
| fetch-depth: 0 | |
| # Use a deploy-grade token that can push tags and open PRs. | |
| # GITHUB_TOKEN works for tag pushes when contents:write is granted | |
| # by the workflow-level permissions block. | |
| persist-credentials: true | |
| - name: Validate VERSION file matches requested version | |
| run: | | |
| FILE_VER="$(tr -d '[:space:]' < VERSION)" | |
| REQ_VER="${{ steps.ver.outputs.version }}" | |
| if [[ "$FILE_VER" != "$REQ_VER" ]]; then | |
| echo "::error::VERSION file ($FILE_VER) does not match requested release ($REQ_VER)." | |
| echo "::error::Bump VERSION on main *before* dispatching Release Nexus, then re-run." | |
| exit 1 | |
| fi | |
| echo "VERSION file matches requested release: $FILE_VER" | |
| - name: Install dependencies | |
| run: | | |
| sudo apt-get update -q | |
| sudo apt-get install -y jq | |
| sudo wget -qO /usr/local/bin/yq \ | |
| https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 | |
| sudo chmod +x /usr/local/bin/yq | |
| - name: Validate roster/index.yaml schema | |
| run: | | |
| jq empty schemas/*.json | |
| yq eval '.' roster/index.yaml > /dev/null | |
| - name: Resolve commit and tree | |
| id: shas | |
| run: | | |
| COMMIT="$(git rev-parse HEAD)" | |
| TREE="$(git rev-parse 'HEAD^{tree}')" | |
| echo "commit=$COMMIT" >> "$GITHUB_OUTPUT" | |
| echo "tree=$TREE" >> "$GITHUB_OUTPUT" | |
| echo "Commit: $COMMIT" | |
| echo "Tree: $TREE" | |
| - name: Build canonical archive | |
| id: archive | |
| run: | | |
| VERSION="${{ steps.ver.outputs.version }}" | |
| PREFIX="eidolons-${VERSION}/" | |
| ARCHIVE="eidolons-${VERSION}.tar.gz" | |
| # Produce the tar and gzip — deterministic with git archive. | |
| git archive --format=tar --prefix="$PREFIX" HEAD | gzip > "$ARCHIVE" | |
| # Also produce the raw tar for sha256 computation (mirrors what | |
| # nexus_verify_release computes on the consumer side). | |
| git archive --format=tar --prefix="$PREFIX" HEAD > "eidolons-${VERSION}.tar" | |
| if command -v sha256sum >/dev/null 2>&1; then | |
| ARCHIVE_SHA256="$(sha256sum "eidolons-${VERSION}.tar" | awk '{print $1}')" | |
| else | |
| ARCHIVE_SHA256="$(shasum -a 256 "eidolons-${VERSION}.tar" | awk '{print $1}')" | |
| fi | |
| echo "archive_sha256=$ARCHIVE_SHA256" >> "$GITHUB_OUTPUT" | |
| echo "archive_file=$ARCHIVE" >> "$GITHUB_OUTPUT" | |
| echo "Archive: $ARCHIVE" | |
| echo "SHA-256: $ARCHIVE_SHA256" | |
| rm -f "eidolons-${VERSION}.tar" | |
| - name: Extract CHANGELOG section for release notes | |
| id: notes | |
| run: | | |
| VERSION="${{ steps.ver.outputs.version }}" | |
| # Extract the [VERSION] section from CHANGELOG.md. | |
| NOTES="$(awk "/^## \[$VERSION\]/,/^## \[/{if(/^## \[/ && !/^## \[$VERSION\]/) exit; print}" CHANGELOG.md \ | |
| | tail -n +2 | head -n -1 || echo "See CHANGELOG.md for details.")" | |
| # Write to file so multiline passes cleanly to the next step. | |
| printf '%s\n' "$NOTES" > /tmp/release-notes.md | |
| echo "notes_file=/tmp/release-notes.md" >> "$GITHUB_OUTPUT" | |
| - name: Create tag | |
| # On workflow_dispatch we tag the current main HEAD here; on push:tags | |
| # the tag already exists (it's how the workflow was triggered) and we | |
| # short-circuit. Mirrors eidolon-release-template.yml's create-tag step. | |
| run: | | |
| set -euo pipefail | |
| TAG="${{ steps.ver.outputs.tag }}" | |
| if git rev-parse --verify --quiet "refs/tags/$TAG" >/dev/null \ | |
| || git ls-remote --tags --exit-code origin "$TAG" >/dev/null 2>&1; then | |
| echo "::notice::Tag $TAG already exists — skipping tag creation (push:tags trigger or re-run)." | |
| exit 0 | |
| fi | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| # Annotated tag — `git rev-parse` returns the tag-object SHA; consumers | |
| # that need the commit SHA must use `^{}` to dereference. The roster | |
| # block written below records the commit SHA explicitly so consumers | |
| # do not have to dereference at install time. | |
| git tag -a "$TAG" -m "Release $TAG" | |
| git push origin "$TAG" | |
| echo "Created tag $TAG at $(git rev-parse 'HEAD')" | |
| - name: Update roster/index.yaml with release metadata | |
| run: | | |
| set -euo pipefail | |
| VERSION="${{ steps.ver.outputs.version }}" | |
| COMMIT="${{ steps.shas.outputs.commit }}" | |
| TREE="${{ steps.shas.outputs.tree }}" | |
| SHA256="${{ steps.archive.outputs.archive_sha256 }}" | |
| NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| # Use yq to update the release metadata in-place. Also advance the | |
| # current-version pointers (version / versions.latest / pins.stable) | |
| # to the version being released — VERSION on main is monotonic and was | |
| # already validated to match the requested release above, so $VERSION | |
| # is the latest. Without this, these pointers stay frozen at the first | |
| # release and roster-health verifies the wrong (stale) tag forever. | |
| yq e ".nexus.versions.releases[\"$VERSION\"].commit = \"$COMMIT\" | | |
| .nexus.versions.releases[\"$VERSION\"].tree = \"$TREE\" | | |
| .nexus.versions.releases[\"$VERSION\"].archive_sha256 = \"$SHA256\" | | |
| .nexus.versions.releases[\"$VERSION\"].released_at = \"$NOW\" | | |
| .nexus.version = \"$VERSION\" | | |
| .nexus.versions.latest = \"$VERSION\" | | |
| .nexus.versions.pins.stable = \"$VERSION\"" \ | |
| -i roster/index.yaml | |
| echo "Updated roster nexus release block + version pointers for v$VERSION" | |
| - name: Commit and open PR for roster update | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| VERSION="${{ steps.ver.outputs.version }}" | |
| BRANCH="chore/nexus-release-${VERSION}" | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git checkout -b "$BRANCH" | |
| git add roster/index.yaml | |
| git commit \ | |
| -m "chore(roster): record nexus v${VERSION} release integrity metadata" \ | |
| -m "Commit: ${{ steps.shas.outputs.commit }}" \ | |
| -m "Tree: ${{ steps.shas.outputs.tree }}" \ | |
| -m "SHA256: ${{ steps.archive.outputs.archive_sha256 }}" | |
| git push origin "$BRANCH" | |
| { | |
| printf 'Automated roster update from the release-nexus workflow.\n\n' | |
| printf 'Records the commit, tree, and archive_sha256 for nexus v%s under ' "$VERSION" | |
| printf '`nexus.versions.releases["%s"]` in `roster/index.yaml`.\n\n' "$VERSION" | |
| printf '**Do not edit this PR manually.** Values are derived deterministically ' | |
| printf 'from the tag at `v%s` by the release workflow.\n' "$VERSION" | |
| } > /tmp/roster-pr-body.md | |
| gh pr create \ | |
| --title "chore(roster): record nexus v${VERSION} release integrity metadata" \ | |
| --body-file /tmp/roster-pr-body.md \ | |
| --base main \ | |
| --head "$BRANCH" | |
| - name: Create GitHub Release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| VERSION="${{ steps.ver.outputs.version }}" | |
| ARCHIVE="${{ steps.archive.outputs.archive_file }}" | |
| # Tag was created earlier in this same workflow run (or pre-exists | |
| # via push:tags trigger). --verify-tag would fetch from origin to | |
| # confirm the local tag matches; this round-trip is safe but | |
| # unnecessary because we just pushed it ourselves. Drop the flag | |
| # to avoid a transient race window where origin hasn't propagated | |
| # the push yet. | |
| gh release create "v${VERSION}" \ | |
| "$ARCHIVE" \ | |
| --title "eidolons v${VERSION}" \ | |
| --notes-file "${{ steps.notes.outputs.notes_file }}" |