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
188 changes: 0 additions & 188 deletions .claude/skills/new-release/SKILL.md

This file was deleted.

63 changes: 63 additions & 0 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
name: release
description: Cut a FeedZero release end-to-end and unattended — draft curated landing notes, publish the changelog feed (landing first), then trigger CI to bump, tag, and publish the Docker image. Version is auto-derived from conventional commits.
argument-hint: "[X.Y.Z to force a version] [--dry-run]"
---

# /release

Cut a release with one command. Supersedes the old `/new-release` (which
never created a git tag and had stale paths).

## Inputs
- Optional explicit version `X.Y.Z` (else derived from conventional commits).
- `--dry-run`: compute + draft + lint and print everything; make NO writes.

## Preconditions (ABORT if any fail)
1. feedzero working tree clean, on `main`, up to date (`git fetch && git status`).
2. `npm test` and `npx -p typescript@6.0.3 tsc --noEmit` both green.
3. Landing repo present at `../feedzero-landing`, clean, on `main`.

## Steps

1. **Last version**:
`node -e "import('../feedzero-landing/releases.mjs').then(m=>console.log(m.releases[0].version))"`.
2. **Commits since**: find the boundary — the commit that bumped to the last
version (search `git log` for `release: v<last>` or the tag `v<last>`),
then `git log --pretty=%s <boundary>..HEAD`. Collect the subject lines.
3. **Version**: import `scripts/release/compute-version.mjs`;
`computeVersion(last, subjects)`. If `null` → ABORT "nothing to release".
If an explicit `X.Y.Z` arg was given, use it instead.
4. **Draft notes**: `draftNotes(subjects, { version, date: <now ISO> })` from
`scripts/release/draft-notes.mjs`.
5. **Lint**: `lintNotes(entry)` from `scripts/release/lint-notes.mjs`.
Auto-fix `fixable` violations (append period, em-dash→comma, strip `!`);
if any non-fixable remain → ABORT and show them. Hand-edit the draft entry
for tone before continuing — the lint only enforces mechanics.
6. **--dry-run?** print the version + entry + planned git ops and STOP here.
7. **Write landing**: prepend the entry object to the `releases` array in
`../feedzero-landing/releases.mjs`, then
`cd ../feedzero-landing && node build-releases.mjs`. Verify `releases.xml`'s
first `<entry>` is the new version.
8. **Push landing FIRST**:
`cd ../feedzero-landing && git add releases.mjs releases.xml index.html && git commit -m "release: v<version> — <title>" && git push origin main`.
9. **Wait for landing live**: poll `https://feedzero.app/releases.xml` (every
15s, up to ~5 min) until it contains `feedzero:release:<version>`. Timeout
→ ABORT (do NOT trigger feedzero; the landing-first invariant must hold).
10. **Trigger feedzero CI**:
`gh workflow run release.yml --repo forcingfx/feedzero -f version=<version>`.
11. **Report**: print the landing commit, the feed URL, and the CI run link
(`gh run list --workflow=release.yml -L1`). Done — the user can walk away;
CI bumps `package.json`, refreshes the fixture, tests, tags, and publishes.

## Notes
- NEVER change existing `<id>` values (`feedzero:release:*`,
`feedzero:changelog`) — that makes every subscriber re-import old entries.
- Notes are editable after the fact: edit `releases.mjs`, re-run
`build-releases.mjs`, push landing.
- **Resume after partial failure**: if `releases.mjs` already has an entry for
`<version>`, skip steps 4–8 and resume at step 9 (poll) → 10 (trigger CI).
- Screenshot / bento card / LinkedIn post are out of scope here — run those
manually if wanted.
- One-time setup (Actions write permission, or PAT fallback) is documented in
`docs/operations/self-host-image-publishing.md`.
37 changes: 31 additions & 6 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ on:
tags:
- 'v*.*.*'
workflow_dispatch:
workflow_call:
inputs:
version:
description: "Release version without leading v (e.g. 0.12.0). Set when called by release.yml."
required: true
type: string

concurrency:
group: docker-publish-${{ github.ref }}
Expand All @@ -52,6 +58,24 @@ jobs:
- name: Check out
uses: actions/checkout@v6

# Resolve the release version from the trigger and (except for the
# SHA-only manual dispatch) refuse to publish if it disagrees with
# package.json. Closes the drift class behind issues #211/#212.
- name: Resolve + verify release version
id: ver
run: |
if [ "${{ github.event_name }}" = "workflow_call" ]; then
VER="${{ inputs.version }}"
else
VER="${GITHUB_REF_NAME#v}"
fi
PKG="$(node -p "require('./package.json').version")"
if [ "${{ github.event_name }}" != "workflow_dispatch" ] && [ "$VER" != "$PKG" ]; then
echo "::error::version mismatch: ref/input=$VER package.json=$PKG"; exit 1
fi
echo "version=$VER" >> "$GITHUB_OUTPUT"
echo "minor=${VER%.*}" >> "$GITHUB_OUTPUT"

- name: Set up QEMU (arm64 emulation for cross-arch builds)
uses: docker/setup-qemu-action@v4

Expand Down Expand Up @@ -91,13 +115,14 @@ jobs:
images: |
ghcr.io/${{ github.repository_owner }}/feedzero
${{ secrets.DOCKERHUB_TOKEN != '' && format('docker.io/{0}/feedzero', github.repository_owner) || '' }}
# Tags:
# * On version tag: vX.Y.Z, X.Y, latest
# * On manual dispatch: short SHA
# Tags (driven by the resolved version so tag-push and
# workflow_call behave identically):
# * Release (tag push or workflow_call): vX.Y.Z, X.Y, latest
# * Manual dispatch: short SHA only (a throwaway smoke build)
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=v${{ steps.ver.outputs.version }},enable=${{ github.event_name != 'workflow_dispatch' }}
type=raw,value=${{ steps.ver.outputs.minor }},enable=${{ github.event_name != 'workflow_dispatch' }}
type=raw,value=latest,enable=${{ github.event_name != 'workflow_dispatch' }}
type=sha,enable=${{ github.event_name == 'workflow_dispatch' }}

- name: Build + push
Expand Down
57 changes: 57 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Release feedzero
# Triggered by the local /release skill via `gh workflow run` AFTER the
# landing changelog feed is confirmed live. Bumps the version, refreshes the
# vendored fixture from the live feed, verifies the version lock, runs the
# test suite, tags, and publishes the image via the reusable docker-publish
# workflow. See docs/operations/self-host-image-publishing.md.
on:
workflow_dispatch:
inputs:
version:
description: "Release version without leading v (e.g. 0.12.0)"
required: true
type: string

permissions:
contents: write
packages: write

jobs:
prepare:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm ci
- name: Bump package.json
run: npm version "${{ inputs.version }}" --no-git-tag-version
- name: Refresh vendored fixture from the live landing feed
run: curl -fsSL https://feedzero.app/releases.xml -o tests/fixtures/release-feed.xml
- name: Verify version lock (package.json == feed top == input)
run: |
PKG="$(node -p "require('./package.json').version")"
FEED="$(grep -oE 'feedzero:release:[0-9.]+' tests/fixtures/release-feed.xml | head -1 | cut -d: -f3)"
test "$PKG" = "${{ inputs.version }}" || { echo "::error::package.json $PKG != input ${{ inputs.version }}"; exit 1; }
test "$FEED" = "${{ inputs.version }}" || { echo "::error::landing feed top $FEED != ${{ inputs.version }} (publish landing first)"; exit 1; }
- run: npx tsc --noEmit
- run: npm test
- name: Commit bump + fixture, then tag
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json tests/fixtures/release-feed.xml
git commit -m "release: v${{ inputs.version }}"
git push origin main
git tag "v${{ inputs.version }}"
git push origin "v${{ inputs.version }}"
publish:
needs: prepare
uses: ./.github/workflows/docker-publish.yml
with:
version: ${{ inputs.version }}
secrets: inherit
Loading