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
130 changes: 93 additions & 37 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
name: Release dry run
name: Release

on:
workflow_dispatch:
inputs:
version:
description: Version to build (must match FeedMob::CLI::VERSION)
bump:
description: Version bump applied to the latest release tag.
required: true
type: string
default: patch
type: choice
options:
- patch
- minor
- major
publish:
description: Publish the GitHub Release and prepare the Tap PR (requires the protected release environment).
required: true
Expand All @@ -25,6 +30,9 @@ jobs:
validate:
name: Validate release inputs
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.version.outputs.value }}
previous_tag: ${{ steps.version.outputs.previous_tag }}
steps:
- uses: actions/checkout@v4
with:
Expand All @@ -33,33 +41,40 @@ jobs:
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Validate requested version
- name: Compute the next version from the latest tag
id: version
env:
RELEASE_VERSION: ${{ inputs.version }}
BUMP: ${{ inputs.bump }}
run: |
test "$RELEASE_VERSION" = "$(ruby -Ilib -r feedmob/cli/version -e 'print FeedMob::CLI::VERSION')"
ruby -e 'abort "Invalid version" unless ENV.fetch("RELEASE_VERSION").match?(/\A\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?\z/)'
git fetch origin --tags --force --quiet
latest=$(git tag --list 'v[0-9]*' --sort=-v:refname | head -1)
if [ -z "$latest" ]; then
next=$(ruby -Ilib -r feedmob/cli/version -e 'print FeedMob::CLI::VERSION')
else
next=$(ruby -e '
major, minor, patch = ARGV.fetch(0).sub(/\Av/, "").split(".").map(&:to_i)
case ARGV.fetch(1)
when "major" then major += 1; minor = 0; patch = 0
when "minor" then minor += 1; patch = 0
else patch += 1
end
print [major, minor, patch].join(".")
' "$latest" "$BUMP")
fi
echo "value=$next" >> "$GITHUB_OUTPUT"
echo "previous_tag=$latest" >> "$GITHUB_OUTPUT"
# Publish-only gates: dry runs must be able to build from any branch so
# release changes can be verified before merging.
- name: Verify source commit is on the default branch
if: inputs.publish
env:
RELEASE_VERSION: ${{ inputs.version }}
run: |
case "$RELEASE_VERSION" in
*-*)
echo "Prerelease $RELEASE_VERSION may build from any commit"
;;
*)
git fetch origin main
git merge-base --is-ancestor HEAD origin/main
;;
esac
git fetch origin main --quiet
git merge-base --is-ancestor HEAD origin/main
- name: Verify the version was not released before
if: inputs.publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_VERSION: ${{ steps.version.outputs.value }}
run: |
! gh release view "v$RELEASE_VERSION" --repo feed-mob/feedmob-cli
test -z "$(git ls-remote --tags origin "refs/tags/v$RELEASE_VERSION")"
Expand All @@ -77,13 +92,46 @@ jobs:
run: |
{
echo "- commit: $GITHUB_SHA"
echo "- CLI version: ${{ inputs.version }}"
echo "- latest tag: ${{ steps.version.outputs.previous_tag }}"
echo "- next version: ${{ steps.version.outputs.value }} (${{ inputs.bump }})"
ruby -ryaml -e 'c = YAML.load_file("packaging/release.yml").fetch("tebako"); puts "- Tebako release: #{c.fetch("release_version")}, format: #{c.fetch("format_version")}, Ruby: #{c.fetch("ruby_version")}"'
} >> "$GITHUB_STEP_SUMMARY"

bump:
name: Bump the version on main
needs: validate
runs-on: ubuntu-24.04
permissions:
contents: write
outputs:
sha: ${{ steps.bump.outputs.sha }}
steps:
- uses: actions/checkout@v4
- name: Commit the version bump (publish only)
id: bump
env:
RELEASE_VERSION: ${{ needs.validate.outputs.version }}
PUBLISH: ${{ inputs.publish }}
run: |
if [ "$PUBLISH" = "true" ]; then
git config user.name feedmob-release
git config user.email release@feedmob.invalid
ruby -e "path = 'lib/feedmob/cli/version.rb'; content = File.read(path); updated = content.sub(/VERSION = '[^']+'/, %{VERSION = '#{ENV.fetch('RELEASE_VERSION')}'}); abort('VERSION assignment not found') if updated == content && !content.include?(ENV.fetch('RELEASE_VERSION')); File.write(path, updated)"
git add lib/feedmob/cli/version.rb
git diff --cached --quiet || git commit -m "fm $RELEASE_VERSION"
git push origin HEAD:main
fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Bump summary
run: |
{
echo "- version: ${{ needs.validate.outputs.version }}"
echo "- release commit: ${{ steps.bump.outputs.sha }}"
} >> "$GITHUB_STEP_SUMMARY"

build:
name: Build (${{ matrix.target }})
needs: validate
needs: [validate, bump]
strategy:
fail-fast: false
matrix:
Expand All @@ -100,6 +148,8 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump.outputs.sha }}
- uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
Expand Down Expand Up @@ -130,10 +180,12 @@ jobs:

assemble:
name: Assemble release assets
needs: build
needs: [validate, build]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump.outputs.sha }}
- uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
Expand All @@ -145,7 +197,7 @@ jobs:
- run: script/assemble-homebrew-release --input "$RUNNER_TEMP/release-input" --output "$RUNNER_TEMP/release-output"
- uses: actions/upload-artifact@v4
with:
name: feedmob-cli-${{ inputs.version }}-release-assets
name: feedmob-cli-${{ needs.validate.outputs.version }}-release-assets
path: ${{ runner.temp }}/release-output/*
if-no-files-found: error
retention-days: 7
Expand All @@ -159,49 +211,53 @@ jobs:
publish:
name: Publish GitHub Release
if: inputs.publish
needs: assemble
needs: [validate, bump, assemble]
runs-on: ubuntu-24.04
environment: release
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump.outputs.sha }}
- uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- uses: actions/download-artifact@v4
with:
name: feedmob-cli-${{ inputs.version }}-release-assets
name: feedmob-cli-${{ needs.validate.outputs.version }}-release-assets
path: ${{ runner.temp }}/release-output
- name: Draft, verify, and publish the release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: script/publish-release --version ${{ inputs.version }} --input "$RUNNER_TEMP/release-output"
run: script/publish-release --version ${{ needs.validate.outputs.version }} --input "$RUNNER_TEMP/release-output" --target ${{ needs.bump.outputs.sha }} --previous-tag "${{ needs.validate.outputs.previous_tag }}"
- name: Publish summary
run: 'echo "- published: https://github.com/feed-mob/feedmob-cli/releases/tag/v${{ inputs.version }}" >> "$GITHUB_STEP_SUMMARY"'
run: 'echo "- published: https://github.com/feed-mob/feedmob-cli/releases/tag/v${{ needs.validate.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"'

formula-pr:
name: Prepare Formula PR
if: inputs.publish
needs: publish
needs: [validate, bump, publish]
runs-on: ubuntu-24.04
environment: release
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump.outputs.sha }}
- uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- uses: actions/download-artifact@v4
with:
name: feedmob-cli-${{ inputs.version }}-release-assets
name: feedmob-cli-${{ needs.validate.outputs.version }}-release-assets
path: ${{ runner.temp }}/release-output
- name: Render the Formula
run: script/render-homebrew-formula --version ${{ inputs.version }} --assets-json "$RUNNER_TEMP/release-output/release-assets.json" --output Formula/fm.rb
run: script/render-homebrew-formula --version ${{ needs.validate.outputs.version }} --assets-json "$RUNNER_TEMP/release-output/release-assets.json" --output Formula/fm.rb
# brew style/audit require the formula inside a tap; symlink this repo
# as the feed-mob/tap tap. Downloads are public, no token needed.
- name: Homebrew style and audit
Expand All @@ -217,14 +273,14 @@ jobs:
run: |
git config user.name feedmob-release
git config user.email release@feedmob.invalid
git switch -c "release/fm-${{ inputs.version }}"
git switch -c "release/fm-${{ needs.validate.outputs.version }}"
git add Formula/fm.rb
git commit -m "fm ${{ inputs.version }}"
git push -u origin "release/fm-${{ inputs.version }}"
git commit -m "fm ${{ needs.validate.outputs.version }}"
git push -u origin "release/fm-${{ needs.validate.outputs.version }}"
CHECKSUMS=$(ruby -rjson -e 'JSON.parse(File.read(ARGV[0])).fetch("assets").each_value { |a| puts "#{a.fetch("sha256")} #{a.fetch("name")}" }' "$RUNNER_TEMP/release-output/release-assets.json")
gh pr create \
--title "fm ${{ inputs.version }}" \
--body "Release: https://github.com/feed-mob/feedmob-cli/releases/tag/v${{ inputs.version }}
--title "fm ${{ needs.validate.outputs.version }}" \
--body "Release: https://github.com/feed-mob/feedmob-cli/releases/tag/v${{ needs.validate.outputs.version }}

Built and verified by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID} (four native targets, real Tebako press, Linux credential smoke).

Expand All @@ -234,4 +290,4 @@ jobs:

Manual review required; the release workflow never merges Formula PRs."
- name: Formula PR summary
run: echo "- Formula PR opened from branch release/fm-${{ inputs.version }}; review and merge manually" >> "$GITHUB_STEP_SUMMARY"
run: echo "- Formula PR opened from branch release/fm-${{ needs.validate.outputs.version }}; review and merge manually" >> "$GITHUB_STEP_SUMMARY"
30 changes: 19 additions & 11 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,24 +61,32 @@ script/render-homebrew-formula --version 0.1.0 \

## Workflow

`.github/workflows/release.yml` is manual (`workflow_dispatch`):
`.github/workflows/release.yml` is manual (`workflow_dispatch`). There is no
version input: the next version is computed from the latest release tag plus
the `bump` input (`patch`/`minor`/`major`, default `patch`).

- `publish=false` (default) is a dry run: build, verify, keep workflow
artifacts. Linux builds additionally run `script/smoke-release-auth`, an
end-to-end encrypted-file credential login/status/logout check against a
loopback fake API with a sentinel token.
- `publish=true` performs the release. Write jobs declare
`environment: release` (deployment restricted to `main`) and require the
`confirm` input to be exactly `release`; the validate job also enforces
default-branch ancestry and that the version was not released before.
- `publish=true` performs the release. The validate job enforces
default-branch ancestry, that the computed version was not released before,
and that the `confirm` input is exactly `release`; the write jobs declare
`environment: release` (deployment restricted to `main`).

Publish sequence:

1. The `publish` job runs `script/publish-release`: create a draft Release,
upload the four archives plus `SHA256SUMS`, read every asset back via the
API (name/size/digest), then undraft. Any failure leaves a draft; a
published release is never overwritten.
2. The `formula-pr` job renders `Formula/fm.rb` from the assembled manifest,
1. The `bump` job commits the `version.rb` bump to `main` as the release bot
and every later job builds from that commit, so `fm version` reports the
released version. A failed run leaves no tag behind, so re-running computes
the same version again.
2. The `publish` job runs `script/publish-release`: create a draft Release at
the bump commit, auto-generate "What's Changed" notes from merged PRs since
the previous tag (checksums appended), upload the four archives plus
`SHA256SUMS`, read every asset back via the API (name/size/digest), then
undraft. Any failure leaves a draft; a published release is never
overwritten.
3. The `formula-pr` job renders `Formula/fm.rb` from the assembled manifest,
runs `brew style` and `brew audit --online`, and opens a
`release/fm-<version>` PR in this repo. A human reviews and merges it —
the workflow never merges Formula PRs.
Expand All @@ -89,5 +97,5 @@ needed is the `release` environment restricted to the `main` branch.
To publish:

```sh
gh workflow run release.yml --ref main -f version=<X.Y.Z> -f publish=true -f confirm=release
gh workflow run release.yml --ref main -f bump=patch -f publish=true -f confirm=release
```
20 changes: 15 additions & 5 deletions script/publish-release
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,15 @@ options = {}
OptionParser.new do |parser|
parser.on('--version VERSION') { |value| options[:version] = value }
parser.on('--input PATH') { |path| options[:input] = File.expand_path(path) }
parser.on('--target SHA') { |value| options[:target] = value }
parser.on('--previous-tag TAG') { |value| options[:previous_tag] = value }
end.parse!

version = options.fetch(:version)
input = options.fetch(:input)
abort "Invalid version: #{version}" unless version.match?(/\A\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?\z/)
target = options[:target]
abort "Invalid target: #{target}" if target && !target.match?(/\A[0-9a-f]{40}\z/)
abort 'GH_TOKEN is not set' if ENV['GH_TOKEN'].to_s.empty?

manifest_path = File.join(input, 'release-assets.json')
Expand All @@ -46,11 +50,17 @@ tag = "v#{version}"
_view, _err, view_status = Open3.capture3('gh', 'release', 'view', tag, '--repo', REPO)
abort "#{tag} already exists; never overwrite a release" if view_status.success?

notes = ["fm #{version}", '', '```', File.read(sums_path).strip, '```'].join("\n")
gh!(
'release', 'create', tag, '--repo', REPO, '--draft',
'--title', "fm #{version}", '--notes', notes, *uploads
)
# Auto-generated "What's Changed" notes like cli/cli releases, with the
# checksums appended.
generate_args = ['-X', 'POST', "repos/#{REPO}/releases/generate-notes", '-f', "tag_name=#{tag}"]
previous_tag = options[:previous_tag].to_s
generate_args += ['-f', "previous_tag_name=#{previous_tag}"] unless previous_tag.empty?
generated = JSON.parse(gh!('api', *generate_args))
notes = [generated.fetch('body'), '', '## Checksums', '', '```', File.read(sums_path).strip, '```'].join("\n")

create_args = ['release', 'create', tag, '--repo', REPO, '--draft', '--title', "fm #{version}", '--notes', notes]
create_args += ['--target', target] if target
gh!(*create_args, *uploads)
puts "Created draft release #{tag}"

# releases/tags/<tag> 404s for drafts (no git tag exists until publish), so
Expand Down
11 changes: 11 additions & 0 deletions test/release_packaging_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,17 @@ def test_publish_release_rejects_a_manifest_version_mismatch
end
end

def test_publish_release_rejects_an_invalid_target
_stdout, stderr, status = Open3.capture3(
{ 'GH_TOKEN' => 'unused' },
RbConfig.ruby, File.join(PROJECT_ROOT, 'script', 'publish-release'),
'--version', '0.1.0', '--input', __dir__, '--target', 'not-a-sha'
)

refute_predicate status, :success?
assert_includes stderr, 'Invalid target'
end

def test_prepare_release_root_contains_only_runtime_application_files
Dir.mktmpdir('feedmob-cli-release-root') do |temporary_directory|
destination = File.join(temporary_directory, 'root')
Expand Down
Loading