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

# On every push to the default branch, tag-and-release the NEWEST dated
# CHANGELOG version when it has no git tag yet — WITHOUT a PAT. A tag pushed with
# the built-in GITHUB_TOKEN deliberately does NOT re-trigger release.yml's
# tag-push event, so this workflow instead creates the tag and then invokes
# release.yml directly as a reusable workflow (the `release` job below). That
# keeps the whole publish path inside GitHub's own token model — no personal
# access token, no elevated secret.
#
# Only the NEWEST dated version is ever tagged: older CHANGELOG versions are left
# alone, because tagging them at the current HEAD would mis-point an immutable
# tag at the wrong code. Idempotent: when the newest version is already tagged,
# `detect` reports should_release=false and neither `tag` nor `release` runs, so
# an ordinary (non-release) push to main does nothing.
on:
push:
branches: [main]

# Serialise: never let two auto-release runs race to create the same tag, and
# never cancel one mid-flight — a half-created tag or release is worse than a
# queued one.
concurrency:
group: auto-release
cancel-in-progress: false

# Floor for the workflow: read-only. Each job elevates itself to exactly the
# scopes it needs, and no more.
permissions:
contents: read

jobs:
# Decide whether the newest dated CHANGELOG version still needs a tag. Pure
# read: no writes, no pushes, contents: read only.
detect:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
version: ${{ steps.detect.outputs.version }}
should_release: ${{ steps.detect.outputs.should_release }}
steps:
- name: Check out the pushed commit (full history + tags)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# fetch-depth: 0 pulls every tag so the existence check below is
# authoritative; a shallow clone would omit tags and mis-report.
fetch-depth: 0
persist-credentials: false

- name: Detect the newest untagged CHANGELOG version
id: detect
# No ${{ }} interpolation in this script (injection-safe, zizmor): it
# reads CHANGELOG.md from the checkout and writes only to $GITHUB_OUTPUT.
run: |
set -euo pipefail
# The first heading of the form "## [X.Y.Z] - <date>" is the newest
# dated release. Requiring the " - " date separator skips the
# "## [Unreleased]" heading, which carries no date.
version="$(grep -m1 -E '^## \[[0-9]+\.[0-9]+\.[0-9]+\] - ' CHANGELOG.md \
| sed -E 's/^## \[([0-9]+\.[0-9]+\.[0-9]+)\] - .*/\1/' || true)"
if [ -z "$version" ]; then
echo "No dated CHANGELOG version found; nothing to release."
echo "should_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
tag="v$version"
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
echo "$tag already exists; nothing to release."
echo "should_release=false" >> "$GITHUB_OUTPUT"
else
echo "$tag has no git tag; will tag and release."
echo "should_release=true" >> "$GITHUB_OUTPUT"
fi

# Create and push the annotated tag with the GITHUB_TOKEN. This is the ONLY
# push this automation makes, and it targets a NEW tag ref only — never a
# branch. A GITHUB_TOKEN-pushed tag raises no tag-push event, which is why the
# release below invokes release.yml explicitly rather than relying on the tag.
tag:
needs: detect
if: needs.detect.outputs.should_release == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write # push the new tag ref
steps:
- name: Check out the pushed commit
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# github.sha = this main push's HEAD, the immutable commit to tag.
ref: ${{ github.sha }}
# persist-credentials: true is REQUIRED here so `git push` below
# authenticates as the GITHUB_TOKEN. Scoped to this single tag-creating
# job whose only step pushes one new tag ref; no other step runs, so
# the persisted credential is never exposed to untrusted input.
persist-credentials: true

- name: Create and push the annotated tag at the released commit
# Values arrive via env (no ${{ }} inside the script): injection-safe.
env:
TAG: v${{ needs.detect.outputs.version }}
COMMIT: ${{ github.sha }}
run: |
set -euo pipefail
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
# Annotated tag at github.sha — the exact commit this push built and the
# commit release.yml will check out (github.sha) and ship.
git tag -a "$TAG" -m "ferry $TAG" "$COMMIT"
git push origin "refs/tags/$TAG"

# Cut the release by calling release.yml as a reusable workflow. Because it is
# CALLED (not triggered by the tag push), github.sha inside release.yml is this
# run's HEAD = the commit just tagged, so both of release.yml's jobs check out
# and ship exactly that commit — the same anti-tag-move property the tag-push
# entry point has. A called workflow is capped by the caller's grants, so this
# job hands down every scope release.yml's jobs need (contents/id-token/
# attestations: write).
release:
needs: [detect, tag]
if: needs.detect.outputs.should_release == 'true'
permissions:
contents: write # release.yml: create the Release + verify the tag
id-token: write # release.yml: OIDC for build-provenance attestation
attestations: write # release.yml: publish + read back the attestations
uses: ./.github/workflows/release.yml
with:
tag: v${{ needs.detect.outputs.version }}
38 changes: 31 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,36 @@ on:
push:
tags:
- 'v*'
# Also callable as a reusable workflow: the auto-release workflow tags the
# newest CHANGELOG version with the GITHUB_TOKEN (which, by design, does NOT
# re-trigger the tag-push event above) and then invokes this workflow
# explicitly, passing the tag it just created. Both entry points converge on
# the same jobs below; only the tag NAME arrives differently (see TAG).
workflow_call:
inputs:
tag:
required: true
type: string

# Never run two releases of the same tag concurrently, and never cancel a
# release mid-flight — a half-published release is worse than a queued one.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

# The tag name this run releases, derived ONCE for both entry points:
# - workflow_call: `inputs.tag` (the tag the auto-release workflow just made).
# - push-tag: `inputs` is empty, so this falls through to github.ref_name
# (the vX.Y.Z ref that triggered the run).
# Exported to every step as $TAG. Note this is only the tag NAME — neither job
# ever CHECKS OUT this name; both check out github.sha (see each checkout step),
# which is the immutable released commit for BOTH entry points:
# - push-tag: github.sha = the commit the pushed tag pointed at.
# - workflow_call: github.sha = the caller's HEAD = the commit auto-release
# just tagged.
env:
TAG: ${{ inputs.tag || github.ref_name }}

# Floor for the workflow: read-only. The release job below elevates itself to
# contents: write only to create the Release and upload its assets; verify stays
# read-only. No job pushes to any branch.
Expand Down Expand Up @@ -70,9 +93,10 @@ jobs:
# .abcd/development/plans/*<tag>.md plan exists, its Status: line must read
# "shipped in <tag>"; a missing plan is fine (not every patch has one).
# VERSION comes via env (not inlined into the run script) to keep the
# tag ref out of the shell expansion (injection-safe, zizmor).
# tag ref out of the shell expansion (injection-safe, zizmor). $TAG is
# the workflow-level env derived once for both entry points.
env:
VERSION: ${{ github.ref_name }}
VERSION: ${{ env.TAG }}
run: scripts/check-plan-shipped.sh "$VERSION"

- name: Build
Expand Down Expand Up @@ -182,10 +206,10 @@ jobs:
cache: false

- name: Cross-compile the four binaries (version-stamped from the tag)
# VERSION=<tag> stamps `ferry --version` via -ldflags. The binaries built
# VERSION=$TAG stamps `ferry --version` via -ldflags. The binaries built
# here are the ones checksummed, attested, and uploaded below, so the
# manifest, the published assets, and the reported version are all in sync.
run: make build VERSION="${GITHUB_REF_NAME}"
run: make build VERSION="$TAG"

- name: Generate checksums.txt over the four binaries
# Hash the exact binaries built above into a sha256sum-format manifest.
Expand Down Expand Up @@ -218,7 +242,7 @@ jobs:
bin/checksums.txt

- name: Create the GitHub Release and upload the binaries + checksums.txt
run: gh release create "${GITHUB_REF_NAME}" bin/ferry-* bin/checksums.txt --verify-tag --title "${GITHUB_REF_NAME}" --notes ""
run: gh release create "$TAG" bin/ferry-* bin/checksums.txt --verify-tag --title "$TAG" --notes ""
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand All @@ -237,7 +261,7 @@ jobs:
set -euo pipefail
DL="$RUNNER_TEMP/attest-verify"
mkdir -p "$DL"
gh release download "${GITHUB_REF_NAME}" \
gh release download "$TAG" \
--repo "${GITHUB_REPOSITORY}" \
--pattern 'ferry-linux-amd64' \
--dir "$DL"
Expand All @@ -259,7 +283,7 @@ jobs:
# git tags, which are immutable once pushed). It never prunes the release just
# created (--current), and refuses if a newer
# patch than the current one somehow exists. See scripts/prune-releases.sh.
run: scripts/prune-releases.sh --current "${GITHUB_REF_NAME}"
run: scripts/prune-releases.sh --current "$TAG"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand Down
Loading