From 7c1a20465af9641b9e67efff0aa7012dc9adfe4d Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 12 Mar 2026 23:41:12 +0100 Subject: [PATCH] Release --- README.md | 245 ++++++++++++++++++- cmd/stamp/cmd_release_pr.go | 79 ++++++ cmd/stamp/cmd_version.go | 17 +- cmd/stamp/main.go | 1 + docs/workflow.md | 412 +++++++++++++++++++++++++------- internal/git/git.go | 33 +++ internal/gitea/gitea.go | 47 ++++ internal/github/github.go | 46 ++++ internal/releasepr/releasepr.go | 278 +++++++++++++++++++++ internal/semver/semver.go | 19 ++ 10 files changed, 1057 insertions(+), 120 deletions(-) create mode 100644 cmd/stamp/cmd_release_pr.go create mode 100644 internal/releasepr/releasepr.go diff --git a/README.md b/README.md index 3ed4564..2d9c4c3 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ A language-agnostic, changesets-style versioning and changelog tool. Works with `stamp` is inspired by the [Changesets](https://github.com/changesets/changesets) workflow, but without the Node.js dependency. It works by having contributors add small **stamp files** (`.stamp/*.md`) alongside their code changes. When it's time to release, `stamp` consumes those files, bumps versions, updates changelogs, creates git tags, and publishes releases to GitHub or Gitea. +There are two supported release workflows: + +- **Direct release** — run `stamp version` and `stamp publish` locally or in CI on the base branch. +- **Release PR** — run `stamp release-pr` in CI to open a pull request that contains the version bumps. Merging the PR triggers the actual publish step. + ## Installation Via [Mise](https://mise.jdx.dev/): @@ -13,19 +18,19 @@ Via [Mise](https://mise.jdx.dev/): ```toml # mise.toml [tools] -"go:github.com/thokra/stamp/cmd/stamp" = "latest" +"go:git.thokra.dev/thokra/stamp/cmd/stamp" = "latest" ``` Or with `go install`: ```sh -go install github.com/thokra/stamp/cmd/stamp@latest +go install git.thokra.dev/thokra/stamp/cmd/stamp@latest ``` Or build from source: ```sh -git clone https://github.com/thokra/stamp +git clone https://git.thokra.dev/thokra/stamp cd stamp go build -o bin/stamp ./cmd/stamp ``` @@ -130,6 +135,27 @@ Requires `STAMP_REPO=owner/repo` to be set. Detects GitHub vs Gitea automaticall Tag format: `@v` (e.g. `my-app@v1.3.0`). +### `stamp release-pr` + +Creates or updates a **release pull request** — a PR that contains all the version bumps and changelog updates that would be applied by `stamp version`. Merging it acts as the explicit release trigger. + +The command: + +1. Reads all pending stamp files. If there are none, it exits cleanly with no side effects. +2. Fetches `origin` and resets a well-known branch (default: `stamp/release`) from the tip of the base branch, so the branch is always a clean, fast-forwardable head. +3. Applies all version bumps and changelog updates to that branch (identical logic to `stamp version --no-commit`), then commits and force-pushes it. +4. Opens a new PR — or updates the existing one — targeting the base branch. The PR is identified across runs by a hidden marker in the body, so only one stamp release PR exists at a time. + +Once the PR is merged, run `stamp publish` to tag and release. + +| Flag | Description | +|------|-------------| +| `--branch` | Release branch name (default: `stamp/release`) | +| `--base` | Base branch the PR targets (default: from `stamp.toml`, fallback: `main`) | +| `--repo` | Repository slug `owner/repo` (defaults to `STAMP_REPO` or `GITHUB_REPOSITORY`) | +| `--snapshot ` | Pre-release identifier (e.g. `alpha`, `rc`) | +| `--dry-run` / `-n` | Print what would be done without pushing or opening a PR | + ### `stamp comment` Posts or updates a PR comment summarising pending stamps. Useful in CI to remind contributors to add stamp files or to show reviewers what versions will change. @@ -142,6 +168,18 @@ Posts or updates a PR comment summarising pending stamps. Useful in CI to remind | `--pr` | Pull request number (required) | | `--repo` | Repository slug `owner/repo` (defaults to `STAMP_REPO` or `GITHUB_REPOSITORY`) | +### `stamp preview` + +Manages **pre-release mode** for a project. While a project is in pre-release mode, every `stamp version` (and `stamp release-pr`) run produces pre-release versions (e.g. `1.3.0-beta.0`, `1.3.0-beta.1`) instead of normal ones. Exiting preview mode causes the next run to produce a regular release. + +```sh +# Enter pre-release mode — all future version bumps will be pre-releases +stamp preview enter my-app beta + +# Leave pre-release mode — the next version bump will be a normal release +stamp preview exit my-app +``` + ## Stamp File Format Stamp files live in `.stamp/` alongside `stamp.toml`, and use Markdown with YAML or TOML frontmatter. @@ -195,6 +233,7 @@ name = "my-app" path = "apps/my-app" # Path relative to repo root version = "1.2.3" # Current version — updated by `stamp version` changelog = "CHANGELOG.md" # Relative to path (default: CHANGELOG.md) +pre_tag = "" # Set by `stamp preview enter`; leave blank for normal releases [projects.publish] tags = true @@ -209,14 +248,18 @@ changelog = "CHANGELOG.md" # Relative to path (default: CHANGELOG.md) | Variable | Purpose | |----------|---------| -| `STAMP_REPO` | Repository slug `owner/repo` — required for `publish` and `comment` | +| `STAMP_REPO` | Repository slug `owner/repo` — required for `publish`, `comment`, and `release-pr` | | `GITHUB_TOKEN` | GitHub token for releases and PR comments — automatically provided by the GitHub Actions runner; no manual setup needed | | `GITEA_TOKEN` | Gitea access token — **must be created manually** (Gitea Actions does not inject one automatically); create a token in your Gitea account settings and store it as a repository secret | | `GITEA_BASE_URL` | Gitea instance URL (e.g. `https://gitea.example.com`) — also enables Gitea mode | ## CI Integration -### GitHub Actions — PR check +### Workflow: PR check (`stamp comment`) + +Posts a comment on every pull request showing which projects will be bumped and to what version. Warns if no stamp file was added. + +**GitHub Actions** ```yaml # .github/workflows/stamp-check.yml @@ -232,7 +275,7 @@ jobs: - uses: actions/checkout@v4 - name: Install stamp - run: go install github.com/thokra/stamp/cmd/stamp@latest + run: go install git.thokra.dev/thokra/stamp/cmd/stamp@latest - name: Comment on PR run: stamp comment --pr=${{ github.event.pull_request.number }} @@ -241,7 +284,7 @@ jobs: STAMP_REPO: ${{ github.repository }} ``` -### Gitea Actions — PR check +**Gitea Actions** ```yaml # .gitea/workflows/stamp-check.yml @@ -257,7 +300,7 @@ jobs: - uses: actions/checkout@v4 - name: Install stamp - run: go install github.com/thokra/stamp/cmd/stamp@latest + run: go install git.thokra.dev/thokra/stamp/cmd/stamp@latest - name: Comment on PR run: stamp comment --pr=${{ gitea.event.pull_request.number }} @@ -267,6 +310,181 @@ jobs: STAMP_REPO: ${{ gitea.repository }} ``` +--- + +### Workflow: Release PR (`stamp release-pr` + `stamp publish`) + +This is the recommended automation workflow. Whenever a PR with stamp files is merged to the base branch, CI opens (or updates) a release PR. Merging the release PR triggers the actual publish. + +``` +feature branch ──► merge to main ──► stamp release-pr ──► release PR + │ + merge to main + │ + stamp publish ──► tags + releases +``` + +**Step 1 — Open or update the release PR on every push to `main`** + +*GitHub Actions* + +```yaml +# .github/workflows/stamp-release-pr.yml +name: release PR + +on: + push: + branches: [main] + +jobs: + release-pr: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # A full clone is needed so stamp can fetch and reset the release branch. + fetch-depth: 0 + + - name: Install stamp + run: go install git.thokra.dev/thokra/stamp/cmd/stamp@latest + + - name: Create or update release PR + run: stamp release-pr --base=main + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + STAMP_REPO: ${{ github.repository }} +``` + +*Gitea Actions* + +```yaml +# .gitea/workflows/stamp-release-pr.yml +name: release PR + +on: + push: + branches: [main] + +jobs: + release-pr: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install stamp + run: go install git.thokra.dev/thokra/stamp/cmd/stamp@latest + + - name: Create or update release PR + run: stamp release-pr --base=main + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_BASE_URL: ${{ gitea.server_url }} + STAMP_REPO: ${{ gitea.repository }} +``` + +**Step 2 — Publish after the release PR is merged** + +*GitHub Actions* + +```yaml +# .github/workflows/stamp-publish.yml +name: publish + +on: + push: + branches: [main] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install stamp + run: go install git.thokra.dev/thokra/stamp/cmd/stamp@latest + + - name: Publish releases + run: stamp publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + STAMP_REPO: ${{ github.repository }} +``` + +*Gitea Actions* + +```yaml +# .gitea/workflows/stamp-publish.yml +name: publish + +on: + push: + branches: [main] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install stamp + run: go install git.thokra.dev/thokra/stamp/cmd/stamp@latest + + - name: Publish releases + run: stamp publish + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_BASE_URL: ${{ gitea.server_url }} + STAMP_REPO: ${{ gitea.repository }} +``` + +> **Tip:** Both jobs run on every push to `main`. `stamp release-pr` is a no-op once there are no more pending stamp files (i.e. after the release PR has been merged and `stamp publish` has consumed them). `stamp publish` is a no-op if there are no new tags to create. The two jobs are safe to run in parallel or in sequence. + +--- + +### Workflow: Direct release (no PR) + +If you prefer to version and publish in a single step without a release PR, run both commands directly on the base branch: + +```yaml +# .github/workflows/stamp-release.yml +name: release + +on: + push: + branches: [main] + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Install stamp + run: go install git.thokra.dev/thokra/stamp/cmd/stamp@latest + + - name: Version and publish + run: | + stamp version + git push + stamp publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + STAMP_REPO: ${{ github.repository }} +``` + ## Development This project uses [Mise](https://mise.jdx.dev/) to manage the Go toolchain and common tasks. @@ -294,17 +512,18 @@ stamp/ │ ├── changeset/ # Stamp file parsing, writing, and slug generation │ ├── changelog/ # CHANGELOG.md generation and appending │ ├── config/ # stamp.toml loading, validation, and saving -│ ├── git/ # Git operations (tag, commit, push) -│ ├── gitea/ # Gitea API client (releases, PR comments) -│ ├── github/ # GitHub API client (releases, PR comments) +│ ├── git/ # Git operations (tag, commit, push, branch management) +│ ├── gitea/ # Gitea API client (releases, PR comments, release PRs) +│ ├── github/ # GitHub API client (releases, PR comments, release PRs) │ ├── publish/ # Orchestrates tagging and release publishing +│ ├── releasepr/ # Orchestrates release PR creation and updates │ └── semver/ # Semantic version bumping logic ├── examples/ │ └── stamp.toml # Annotated configuration example └── docs/ - └── workflow.md # Detailed workflow guide + └── workflow.md # Detailed workflow guide ``` ## License -MIT +MIT \ No newline at end of file diff --git a/cmd/stamp/cmd_release_pr.go b/cmd/stamp/cmd_release_pr.go new file mode 100644 index 0000000..1102f1e --- /dev/null +++ b/cmd/stamp/cmd_release_pr.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/urfave/cli/v3" + + "git.thokra.dev/thokra/stamp/internal/config" + "git.thokra.dev/thokra/stamp/internal/releasepr" +) + +func releasePRCmd() *cli.Command { + return &cli.Command{ + Name: "release-pr", + Usage: "create or update a release pull request with pending version bumps", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "branch", + Usage: "release branch name (default: stamp/release)", + Value: releasepr.DefaultReleaseBranch, + }, + &cli.StringFlag{ + Name: "base", + Usage: "base branch the PR targets (default: from stamp.toml, fallback: main)", + }, + &cli.StringFlag{ + Name: "repo", + Usage: "repository slug owner/repo (defaults to STAMP_REPO env var)", + }, + &cli.StringFlag{ + Name: "snapshot", + Usage: "pre-release identifier (e.g. alpha, beta, rc) for snapshot releases", + }, + &cli.BoolFlag{ + Name: "dry-run", + Aliases: []string{"n"}, + Usage: "print what would be done without pushing or opening a PR", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + repoRoot, err := findRepoRoot(".") + if err != nil { + return err + } + + cfg, err := config.Load(repoRoot) + if err != nil { + return err + } + + repoSlug := cmd.String("repo") + if repoSlug == "" { + repoSlug = os.Getenv("STAMP_REPO") + } + if repoSlug == "" { + repoSlug = os.Getenv("GITHUB_REPOSITORY") + } + if repoSlug == "" { + return fmt.Errorf("--repo or STAMP_REPO / GITHUB_REPOSITORY env var must be set") + } + + baseBranch := cmd.String("base") + if baseBranch == "" { + baseBranch = cfg.BaseBranch() + } + + return releasepr.Run(ctx, releasepr.Options{ + RepoRoot: repoRoot, + RepoSlug: repoSlug, + BaseBranch: baseBranch, + ReleaseBranch: cmd.String("branch"), + SnapshotID: cmd.String("snapshot"), + DryRun: cmd.Bool("dry-run"), + }) + }, + } +} diff --git a/cmd/stamp/cmd_version.go b/cmd/stamp/cmd_version.go index 59c4e0f..15d3bea 100644 --- a/cmd/stamp/cmd_version.go +++ b/cmd/stamp/cmd_version.go @@ -75,22 +75,7 @@ func versionCmd() *cli.Command { } if preID != "" { - // If the project is already on a pre-release version, just - // increment it. Otherwise promote the highest regular bump - // to its pre-release equivalent so we don't skip a version. - v, _ := semver.CurrentVersion(project.Version) - if v != nil && v.Prerelease() != "" { - highest = changeset.BumpPreRelease - } else { - switch highest { - case changeset.BumpMajor: - highest = changeset.BumpPreMajor - case changeset.BumpMinor: - highest = changeset.BumpPreMinor - default: // patch or anything lower - highest = changeset.BumpPrePatch - } - } + highest = semver.PreReleaseBump(project.Version, highest) } nextVer, err := semver.Bump(project.Version, highest, preID) diff --git a/cmd/stamp/main.go b/cmd/stamp/main.go index 95debb2..d5e8300 100644 --- a/cmd/stamp/main.go +++ b/cmd/stamp/main.go @@ -20,6 +20,7 @@ func main() { publishCmd(), commentCmd(), previewCmd(), + releasePRCmd(), }, } diff --git a/docs/workflow.md b/docs/workflow.md index 42ab3f0..b0442f4 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -3,87 +3,87 @@ `stamp` is a language-agnostic, changesets-style versioning and changelog tool. It works with any project type and supports monorepos. +--- + ## Concepts -| Term | Description | -|------|-------------| -| **Stamp file** | A `.stamp/*.md` file describing a change and which projects it affects | -| **Bump type** | How to increment a version: `major`, `minor`, `patch`, or pre-release variants | -| `stamp version` | Consumes stamp files → bumps versions → updates changelogs | -| `stamp publish` | Creates git tags, releases, and uploads artifacts | +| Term | Meaning | +|------|---------| +| **Stamp file** | A `.stamp/*.md` file describing a change and which projects it affects. Committed alongside the code change that caused it. | +| **Bump type** | How to increment a version: `major`, `minor`, `patch`, or a pre-release variant. | +| **Pending stamps** | Stamp files that have been committed but not yet consumed by a version run. | +| `stamp version` | Consumes pending stamp files → bumps versions in `stamp.toml` → prepends entries to `CHANGELOG.md` files → commits the result. | +| `stamp release-pr` | Same versioning logic as `stamp version`, but commits the changes onto a dedicated branch and opens (or updates) a pull request. The actual release happens when that PR is merged. | +| `stamp publish` | Creates git tags and publishes releases (with optional artifact uploads) to GitHub or Gitea. | --- -## Setup +## Two Release Workflows -### 1. Install stamp +### Direct release -Via [Mise](https://mise.jdx.dev/): +Run `stamp version` and `stamp publish` directly on the base branch. Good for small projects, CLIs, or repos where a human is doing releases manually. -```toml -# mise.toml -[tools] -"go:git.thokra.dev/thokra/stamp/cmd/stamp" = "latest" +``` +feature branch ──► merge to main ──► stamp version ──► git push ──► stamp publish ``` -Or build from source: +### Release PR (recommended for automation) -```sh -go install git.thokra.dev/thokra/stamp/cmd/stamp@latest +`stamp release-pr` runs in CI whenever a change is merged to the base branch. It opens a pull request that contains all the version bumps and changelog updates. Merging that PR is the explicit, reviewable release trigger. A second CI job runs `stamp publish` after the merge. + +``` +feature branch ──► merge to main ──► stamp release-pr ──► release PR + │ + merge to main + │ + stamp publish ──► tags + releases ``` -### 2. Create .stamp/stamp.toml - -Inside the `.stamp/` directory at the root of your repository: - -```toml -[[projects]] -name = "my-app" -path = "." -version = "0.1.0" - - [projects.publish] - tags = true - releases = true - artifacts = ["dist/my-app-*"] -``` - -Because `stamp.toml` lives inside `.stamp/`, git will track the directory without needing a `.gitignore` file in it. - -See [`examples/stamp.toml`](../examples/stamp.toml) for a fully annotated example. +The two CI jobs (create PR and publish) both run on every push to `main`. They are each no-ops when there is nothing for them to do — `stamp release-pr` exits silently if there are no pending stamp files, and `stamp publish` skips any tag that already exists. --- -## Day-to-day Workflow +## Day-to-day: Adding a Stamp File -### Adding a stamp to your PR - -When making a change that should be released, add a stamp file: +When making a code change that should result in a release, add a stamp file alongside it: ```sh -# Interactive +# Interactive wizard stamp add -# Non-interactive (useful in scripts or with AI agents) -stamp add --project=my-app --bump=minor --message="Added X feature" --no-interactive +# Non-interactive — good for scripts and AI agents +stamp add --project=my-app --bump=minor --message="Add X feature" --no-interactive ``` -This creates a `.stamp/.md` file, e.g.: +This creates a file like `.stamp/brave-river.md`: -```markdown +``` --- bumps: my-app: minor --- -Added X feature +Add X feature ``` -> Stamp files live alongside `stamp.toml` in the `.stamp/` directory. +Commit and push the stamp file together with your code. Your CI PR-check job (see below) will update its comment automatically. -Commit and push the stamp file alongside your code changes. +### Bump type reference -### Checking pending stamps +| Type | What it does | Example | +|------|-------------|---------| +| `major` | Breaking change | `1.2.3` → `2.0.0` | +| `minor` | New feature | `1.2.3` → `1.3.0` | +| `patch` | Bug fix | `1.2.3` → `1.2.4` | +| `premajor` | Pre-release major | `1.2.3` → `2.0.0-beta.0` | +| `preminor` | Pre-release minor | `1.2.3` → `1.3.0-beta.0` | +| `prepatch` | Pre-release patch | `1.2.3` → `1.2.4-beta.0` | +| `prerelease` | Increment pre-release counter | `1.2.4-rc.0` → `1.2.4-rc.1` | + +--- + +## Checking Pending Stamps ```sh stamp status @@ -95,36 +95,210 @@ stamp status PROJECT CURRENT NEXT BUMP STAMPS ------- ------- ---- ---- ------ my-app 1.2.3 1.3.0 minor 2 - my-lib 0.1.0 0.1.0 — 0 -``` - -### Releasing - -On your release branch (e.g. after merging PRs): - -```sh -# 1. Bump versions and update changelogs -stamp version - -# 2. Publish tags, releases, and artifacts -STAMP_REPO=owner/repo stamp publish -``` - -For a pre-release snapshot: - -```sh -stamp version --snapshot=alpha -stamp publish + my-lib 0.4.1 0.4.1 — 0 ``` --- -## PR Commenting (CI) +## Workflow A: Direct Release -Use `stamp comment` in your CI pipeline to automatically comment on PRs: +Use this when you release manually or from CI without a release PR. -- ⚠️ **No stamp file?** → warns the author and explains how to add one -- ✅ **Stamps found?** → shows a table of affected projects and next versions +```sh +# 1. Bump versions, update changelogs, and commit +stamp version + +# 2. Push the version commit and tags +git push + +# 3. Create GitHub / Gitea releases and upload artifacts +STAMP_REPO=owner/repo stamp publish +``` + +For a pre-release snapshot (does not require `stamp preview`): + +```sh +stamp version --snapshot=rc +git push +stamp publish +``` + +`--snapshot` overrides the bump type for every affected project so that it produces a pre-release version with the given identifier. It is independent of `stamp preview` mode. + +### Skipping the git commit + +If you want to inspect or further modify the files before committing: + +```sh +stamp version --no-commit +# review the diff, make additional edits… +git add -A && git commit -m "chore: version packages" +``` + +--- + +## Workflow B: Release PR + +Use this for fully automated, reviewable releases in CI. + +### How it works — step by step + +1. **Check for pending stamps.** If none exist, `stamp release-pr` exits immediately with no side effects. This makes it safe to run on every push. + +2. **Fetch origin** and reset the release branch (default: `stamp/release`) from the tip of `origin/`. The branch is always recreated cleanly, so it is always fast-forwardable and never diverges from the base branch. + +3. **Apply version changes** — bump versions in `stamp.toml`, prepend changelog entries, delete consumed stamp files. This is identical to what `stamp version --no-commit` does. + +4. **Commit and force-push** the release branch to `origin`. + +5. **Upsert the pull request.** A hidden marker (``) is embedded in the PR body. On the first run a new PR is opened; on subsequent runs the existing PR's title and body are updated to reflect any newly merged stamp files. Only one stamp release PR is ever open at a time. + +The PR body contains a version table: + +``` +## 📦 Pending releases + +| Project | Current | Next | Bump | +|---------|---------|-------|-------| +| my-app | 1.2.3 | 1.3.0 | minor | +| my-lib | 0.4.1 | 0.4.2 | patch | +``` + +6. **Merge the PR.** Merging triggers the publish job (see below) which creates git tags and GitHub/Gitea releases. + +### CI setup — GitHub Actions + +**Job 1: open / update the release PR** — runs on every push to `main` + +```yaml +# .github/workflows/stamp-release-pr.yml +name: release PR + +on: + push: + branches: [main] + +jobs: + release-pr: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Full clone so stamp can fetch and reset the release branch. + fetch-depth: 0 + + - name: Install stamp + run: go install git.thokra.dev/thokra/stamp/cmd/stamp@latest + + - name: Create or update release PR + run: stamp release-pr --base=main + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + STAMP_REPO: ${{ github.repository }} +``` + +**Job 2: publish after the release PR is merged** — also runs on every push to `main` + +```yaml +# .github/workflows/stamp-publish.yml +name: publish + +on: + push: + branches: [main] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install stamp + run: go install git.thokra.dev/thokra/stamp/cmd/stamp@latest + + - name: Publish releases + run: stamp publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + STAMP_REPO: ${{ github.repository }} +``` + +> Both jobs run on every push to `main`. They are each idempotent and safe to run concurrently: `stamp release-pr` is a no-op when there are no pending stamps; `stamp publish` skips tags that already exist. + +### CI setup — Gitea Actions + +**Job 1: open / update the release PR** + +```yaml +# .gitea/workflows/stamp-release-pr.yml +name: release PR + +on: + push: + branches: [main] + +jobs: + release-pr: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install stamp + run: go install git.thokra.dev/thokra/stamp/cmd/stamp@latest + + - name: Create or update release PR + run: stamp release-pr --base=main + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_BASE_URL: ${{ gitea.server_url }} + STAMP_REPO: ${{ gitea.repository }} +``` + +**Job 2: publish after the release PR is merged** + +```yaml +# .gitea/workflows/stamp-publish.yml +name: publish + +on: + push: + branches: [main] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install stamp + run: go install git.thokra.dev/thokra/stamp/cmd/stamp@latest + + - name: Publish releases + run: stamp publish + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_BASE_URL: ${{ gitea.server_url }} + STAMP_REPO: ${{ gitea.repository }} +``` + +> **Note on `GITEA_TOKEN`:** Gitea Actions does not automatically inject a token the way GitHub Actions does. You must create a personal access token (or a bot account token) in your Gitea account settings with repository read/write permissions, then store it as a repository secret named `GITEA_TOKEN`. + +--- + +## PR Commenting (stamp comment) + +Use `stamp comment` in CI to post a summary comment on every pull request. It keeps reviewers and contributors informed about what will be released. + +- **No stamp file** → the comment warns the author and shows how to add one. +- **Stamps found** → the comment shows a table of affected projects and their projected next versions. + +The comment is created on first run and updated in place on subsequent runs (identified by a hidden marker in the body). ### GitHub Actions @@ -179,20 +353,58 @@ jobs: --- -## Stamp File Format +## Pre-release Mode (stamp preview) -Stamp files live in `.stamp/` alongside `stamp.toml`, and use Markdown with either YAML or TOML frontmatter. +`stamp preview` lets you put an individual project into **pre-release mode**. While in this mode, every `stamp version` or `stamp release-pr` run produces pre-release versions instead of normal ones, regardless of the bump types in the stamp files. + +```sh +# Enter pre-release mode — all future versions for my-app will be pre-releases +stamp preview enter my-app beta +# → next stamp version will produce e.g. 1.3.0-beta.0 + +# If a pre-release version already exists, subsequent runs increment the counter +# → 1.3.0-beta.0 → 1.3.0-beta.1 → 1.3.0-beta.2 … + +# Exit pre-release mode — the next stamp version will produce a normal release +stamp preview exit my-app +# → next stamp version will produce e.g. 1.3.0 +``` + +`stamp preview enter` sets the `pre_tag` field on the project in `stamp.toml` and commits nothing — it is a local config change you commit yourself. `stamp preview exit` clears the field. + +### Typical pre-release workflow + +```sh +# 1. Enter beta mode +stamp preview enter my-app beta +git add .stamp/stamp.toml && git commit -m "chore: enter beta mode for my-app" + +# 2. Normal development — add stamp files as usual, merge PRs +# stamp release-pr will produce beta versions automatically + +# 3. When ready to release, exit beta mode +stamp preview exit my-app +git add .stamp/stamp.toml && git commit -m "chore: exit beta mode for my-app" + +# 4. The next release-pr or stamp version will produce the full release (e.g. 1.3.0) +``` + +--- + +## Stamp File Format Reference + +Stamp files live in `.stamp/` alongside `stamp.toml`, and use Markdown with YAML or TOML frontmatter. ### YAML frontmatter (default) -```markdown +``` --- bumps: my-app: minor my-lib: patch --- -Short description of the change used as the changelog entry. +Short description used as the changelog entry. - Optional bullet points - for more detail @@ -200,7 +412,7 @@ Short description of the change used as the changelog entry. ### TOML frontmatter -```markdown +``` +++ [bumps] my-app = "minor" @@ -209,17 +421,35 @@ my-app = "minor" Short description. ``` -### Valid bump types +A stamp file can affect multiple projects in a single file (as shown above). Each project gets its own changelog entry with the description from the file. -| Type | Description | -|------|-------------| -| `major` | Breaking change (1.x.x → 2.0.0) | -| `minor` | New feature (1.2.x → 1.3.0) | -| `patch` | Bug fix (1.2.3 → 1.2.4) | -| `premajor` | Pre-release major (→ 2.0.0-alpha.0) | -| `preminor` | Pre-release minor (→ 1.3.0-beta.0) | -| `prepatch` | Pre-release patch (→ 1.2.4-rc.0) | -| `prerelease` | Increment pre-release (1.2.4-rc.0 → 1.2.4-rc.1) | +--- + +## Configuration Reference + +```toml +# .stamp/stamp.toml + +[config] +base_branch = "main" # Base branch targeted by release PRs (default: main) + +[[projects]] +name = "my-app" +path = "apps/my-app" # Relative to repository root +version = "1.2.3" # Current version — managed by stamp, do not edit manually +changelog = "CHANGELOG.md" # Relative to `path` (default: CHANGELOG.md) +pre_tag = "" # Set by `stamp preview enter`; blank means normal releases + + [projects.publish] + tags = true # Create an annotated git tag on publish + releases = true # Create a GitHub / Gitea release on publish + artifacts = [ # Glob patterns for files to upload to the release + "apps/my-app/dist/my-app-linux-amd64", + "apps/my-app/dist/my-app-darwin-arm64", + ] +``` + +Tag format: `@v` for monorepos (e.g. `my-app@v1.3.0`), or `v` for single-project repositories where `name` is empty. --- @@ -227,7 +457,7 @@ Short description. | Variable | Purpose | |----------|---------| -| `STAMP_REPO` | Repository slug `owner/repo` used by publish and comment | -| `GITHUB_TOKEN` | GitHub token for releases and PR comments — automatically provided by the GitHub Actions runner; no manual setup needed | -| `GITEA_TOKEN` | Gitea access token — **must be created manually**; create a token in your Gitea account settings and store it as a repository secret | -| `GITEA_BASE_URL` | Gitea instance URL (e.g. `https://gitea.example.com`) — also used to detect Gitea mode | +| `STAMP_REPO` | Repository slug `owner/repo` — required for `publish`, `comment`, and `release-pr` | +| `GITHUB_TOKEN` | GitHub token — automatically injected by GitHub Actions; no setup needed | +| `GITEA_TOKEN` | Gitea access token — must be created manually and stored as a secret | +| `GITEA_BASE_URL` | Gitea instance URL (e.g. `https://gitea.example.com`) — presence of this variable also activates Gitea mode | \ No newline at end of file diff --git a/internal/git/git.go b/internal/git/git.go index 85c34b2..e684d48 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -82,3 +82,36 @@ func TagExists(dir, tag string) (bool, error) { } return strings.TrimSpace(out) == tag, nil } + +// BranchExists returns true if the given local branch exists. +func BranchExists(dir, branch string) (bool, error) { + out, err := Run(dir, "branch", "--list", branch) + if err != nil { + return false, err + } + return strings.TrimSpace(out) != "", nil +} + +// CheckoutBranch checks out an existing branch. +func CheckoutBranch(dir, branch string) error { + _, err := Run(dir, "checkout", branch) + return err +} + +// CheckoutNewBranch creates and checks out a new branch from the given base. +func CheckoutNewBranch(dir, branch, base string) error { + _, err := Run(dir, "checkout", "-B", branch, base) + return err +} + +// PushBranch force-pushes a branch to origin. +func PushBranch(dir, branch string) error { + _, err := Run(dir, "push", "--force-with-lease", "origin", branch) + return err +} + +// FetchOrigin fetches from origin. +func FetchOrigin(dir string) error { + _, err := Run(dir, "fetch", "origin") + return err +} diff --git a/internal/gitea/gitea.go b/internal/gitea/gitea.go index 5cbb87c..4873c1c 100644 --- a/internal/gitea/gitea.go +++ b/internal/gitea/gitea.go @@ -8,6 +8,8 @@ import ( giteaSDK "code.gitea.io/sdk/gitea" ) +const prMarker = "" + // Client wraps the Gitea API. type Client struct { client *giteaSDK.Client @@ -74,6 +76,51 @@ func (c *Client) UploadAsset(releaseID int64, filePath string) error { return nil } +// UpsertPR creates or updates a stamp release PR on the given base branch. +// It identifies the existing PR by scanning open PRs for the hidden marker in the body. +func (c *Client) UpsertPR(head, base, title, body string) (int64, string, error) { + markedBody := prMarker + "\n" + body + + // Search open PRs for an existing stamp release PR (Gitea SDK does not support + // filtering by head branch, so we filter client-side). + prs, _, err := c.client.ListRepoPullRequests(c.owner, c.repo, giteaSDK.ListPullRequestsOptions{ + State: giteaSDK.StateOpen, + }) + if err != nil { + return 0, "", fmt.Errorf("listing pull requests: %w", err) + } + + for _, pr := range prs { + if pr.Head != nil && pr.Head.Name != head { + continue + } + if strings.Contains(pr.Body, prMarker) { + // Update existing PR. + updated, _, err := c.client.EditPullRequest(c.owner, c.repo, pr.Index, + giteaSDK.EditPullRequestOption{ + Title: title, + Body: &markedBody, + }) + if err != nil { + return 0, "", fmt.Errorf("updating pull request #%d: %w", pr.Index, err) + } + return updated.Index, updated.HTMLURL, nil + } + } + + // Create a new PR. + pr, _, err := c.client.CreatePullRequest(c.owner, c.repo, giteaSDK.CreatePullRequestOption{ + Head: head, + Base: base, + Title: title, + Body: markedBody, + }) + if err != nil { + return 0, "", fmt.Errorf("creating pull request: %w", err) + } + return pr.Index, pr.HTMLURL, nil +} + // UpsertPRComment posts or updates a stamped PR comment. func (c *Client) UpsertPRComment(prNumber int, body string) error { markedBody := commentMarker + "\n" + body diff --git a/internal/github/github.go b/internal/github/github.go index 1c2f994..168fb76 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -94,3 +94,49 @@ func (c *Client) UpsertPRComment(ctx context.Context, prNumber int, body string) &gh.IssueComment{Body: gh.Ptr(markedBody)}) return err } + +const prMarker = "" + +// UpsertPR creates or updates a release PR from head into base. +// It identifies a previously-created PR by the hidden marker in the body. +// Returns the PR number and the URL. +func (c *Client) UpsertPR(ctx context.Context, head, base, title, body string) (int, string, error) { + markedBody := prMarker + "\n" + body + + // Search open PRs from this head branch. + prs, _, err := c.client.PullRequests.List(ctx, c.owner, c.repo, &gh.PullRequestListOptions{ + State: "open", + Head: c.owner + ":" + head, + Base: base, + }) + if err != nil { + return 0, "", fmt.Errorf("listing pull requests: %w", err) + } + + for _, pr := range prs { + if strings.Contains(pr.GetBody(), prMarker) { + // Update existing PR. + updated, _, err := c.client.PullRequests.Edit(ctx, c.owner, c.repo, pr.GetNumber(), + &gh.PullRequest{ + Title: gh.Ptr(title), + Body: gh.Ptr(markedBody), + }) + if err != nil { + return 0, "", fmt.Errorf("updating pull request: %w", err) + } + return updated.GetNumber(), updated.GetHTMLURL(), nil + } + } + + // Create new PR. + pr, _, err := c.client.PullRequests.Create(ctx, c.owner, c.repo, &gh.NewPullRequest{ + Title: gh.Ptr(title), + Head: gh.Ptr(head), + Base: gh.Ptr(base), + Body: gh.Ptr(markedBody), + }) + if err != nil { + return 0, "", fmt.Errorf("creating pull request: %w", err) + } + return pr.GetNumber(), pr.GetHTMLURL(), nil +} diff --git a/internal/releasepr/releasepr.go b/internal/releasepr/releasepr.go new file mode 100644 index 0000000..d3cbb7a --- /dev/null +++ b/internal/releasepr/releasepr.go @@ -0,0 +1,278 @@ +package releasepr + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "git.thokra.dev/thokra/stamp/internal/changelog" + "git.thokra.dev/thokra/stamp/internal/changeset" + "git.thokra.dev/thokra/stamp/internal/config" + "git.thokra.dev/thokra/stamp/internal/git" + giteaClient "git.thokra.dev/thokra/stamp/internal/gitea" + ghClient "git.thokra.dev/thokra/stamp/internal/github" + "git.thokra.dev/thokra/stamp/internal/semver" +) + +const DefaultReleaseBranch = "stamp/release" + +// Options controls release-pr behaviour. +type Options struct { + // RepoRoot is the absolute path to the repository root. + RepoRoot string + // RepoSlug is "owner/repo", required for API calls. + RepoSlug string + // BaseBranch is the branch the PR targets (e.g. "main"). + BaseBranch string + // ReleaseBranch is the branch stamp commits to (default: "stamp/release"). + ReleaseBranch string + // SnapshotID is an optional pre-release identifier forwarded to stamp version logic. + SnapshotID string + // DryRun prints what would happen without pushing or opening a PR. + DryRun bool +} + +// BumpSummary holds the computed version change for one project. +type BumpSummary struct { + Name string + Current string + Next string + Bump string +} + +// Run is the main entry point for `stamp release-pr`. +// It versions all pending stamps, commits to the release branch, pushes, and +// opens or updates a pull request against the base branch. +func Run(ctx context.Context, opts Options) error { + cfg, err := config.Load(opts.RepoRoot) + if err != nil { + return err + } + + stampDir := filepath.Join(opts.RepoRoot, cfg.ChangesetDir()) + + changesets, err := changeset.ReadAll(stampDir) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("reading stamp files: %w", err) + } + if len(changesets) == 0 { + fmt.Println("No pending stamps found — nothing to do.") + return nil + } + + // Compute version bumps for the PR body before mutating anything. + summaries, err := computeSummaries(cfg, changesets, opts.SnapshotID) + if err != nil { + return err + } + + if opts.DryRun { + fmt.Println("[dry-run] Would create/update release PR with:") + for _, s := range summaries { + fmt.Printf(" %s: %s → %s (%s)\n", s.Name, s.Current, s.Next, s.Bump) + } + return nil + } + + // Fetch so we have an up-to-date origin/. + fmt.Println("Fetching from origin...") + if err := git.FetchOrigin(opts.RepoRoot); err != nil { + return fmt.Errorf("fetching origin: %w", err) + } + + // Create or reset the release branch from origin/ so it is always a + // clean, fast-forwardable branch on top of the latest base. + base := "origin/" + opts.BaseBranch + fmt.Printf("Resetting %s from %s...\n", opts.ReleaseBranch, base) + if err := git.CheckoutNewBranch(opts.RepoRoot, opts.ReleaseBranch, base); err != nil { + return fmt.Errorf("checking out release branch: %w", err) + } + + // Apply version changes (same logic as `stamp version --no-commit`). + changedFiles, err := applyVersions(opts.RepoRoot, cfg, changesets, opts.SnapshotID) + if err != nil { + return err + } + + // Stage and commit. + if err := git.Add(opts.RepoRoot, changedFiles...); err != nil { + return fmt.Errorf("staging files: %w", err) + } + commitMsg := buildCommitMessage(summaries) + if err := git.Commit(opts.RepoRoot, commitMsg); err != nil { + return fmt.Errorf("committing version bump: %w", err) + } + + // Push the release branch. + fmt.Printf("Pushing %s...\n", opts.ReleaseBranch) + if err := git.PushBranch(opts.RepoRoot, opts.ReleaseBranch); err != nil { + return fmt.Errorf("pushing release branch: %w", err) + } + + // Open or update the PR. + title := buildPRTitle(summaries) + body := buildPRBody(summaries) + + prNumber, prURL, err := upsertPR(ctx, opts, title, body) + if err != nil { + return err + } + + fmt.Printf("✓ Release PR #%d ready: %s\n", prNumber, prURL) + return nil +} + +// computeSummaries calculates the next version for every project that has pending stamps. +func computeSummaries(cfg *config.Config, changesets []*changeset.Changeset, snapshotID string) ([]BumpSummary, error) { + projectBumps := semver.ProjectBumps(changesets) + var summaries []BumpSummary + + for _, project := range cfg.Projects { + bumps := projectBumps[project.Name] + if len(bumps) == 0 { + continue + } + + highest := semver.HighestBump(bumps) + preID := snapshotID + if preID == "" { + preID = project.PreTag + } + if preID != "" { + highest = semver.PreReleaseBump(project.Version, highest) + } + + next, err := semver.Bump(project.Version, highest, preID) + if err != nil { + return nil, fmt.Errorf("computing next version for %s: %w", project.Name, err) + } + + summaries = append(summaries, BumpSummary{ + Name: project.Name, + Current: project.Version, + Next: next, + Bump: string(highest), + }) + } + return summaries, nil +} + +// applyVersions runs the version-bump logic and returns the list of files that were changed. +// It mutates cfg.Projects in place (same as cmd_version.go). +func applyVersions(repoRoot string, cfg *config.Config, changesets []*changeset.Changeset, snapshotID string) ([]string, error) { + projectBumps := semver.ProjectBumps(changesets) + now := time.Now() + var changedFiles []string + + for i := range cfg.Projects { + project := &cfg.Projects[i] + bumps := projectBumps[project.Name] + if len(bumps) == 0 { + continue + } + + highest := semver.HighestBump(bumps) + preID := snapshotID + if preID == "" { + preID = project.PreTag + } + if preID != "" { + highest = semver.PreReleaseBump(project.Version, highest) + } + + nextVer, err := semver.Bump(project.Version, highest, preID) + if err != nil { + return nil, fmt.Errorf("bumping %s: %w", project.Name, err) + } + + var body string + for _, cs := range changesets { + if _, ok := cs.Bumps[project.Name]; ok { + if cs.Description != "" { + body += "- " + cs.Description + "\n" + } + } + } + + clPath := project.ChangelogPath(repoRoot) + if err := changelog.Append(clPath, changelog.Entry{ + Version: nextVer, + Date: now, + Description: body, + }); err != nil { + return nil, fmt.Errorf("updating changelog for %s: %w", project.Name, err) + } + changedFiles = append(changedFiles, clPath) + + fmt.Printf(" %s: %s → %s\n", project.Name, project.Version, nextVer) + project.Version = nextVer + } + + // Persist updated versions to stamp.toml. + if err := config.Save(repoRoot, cfg); err != nil { + return nil, fmt.Errorf("saving stamp.toml: %w", err) + } + changedFiles = append(changedFiles, config.ConfigPath(repoRoot)) + + // Delete consumed stamp files. + stampDir := filepath.Join(repoRoot, cfg.ChangesetDir()) + if err := changeset.DeleteAll(stampDir); err != nil { + return nil, fmt.Errorf("deleting stamp files: %w", err) + } + changedFiles = append(changedFiles, stampDir) + + return changedFiles, nil +} + +func buildCommitMessage(summaries []BumpSummary) string { + msg := "chore: version packages\n\n" + for _, s := range summaries { + msg += fmt.Sprintf("- %s: %s → %s\n", s.Name, s.Current, s.Next) + } + return msg +} + +func buildPRTitle(summaries []BumpSummary) string { + if len(summaries) == 1 { + s := summaries[0] + return fmt.Sprintf("chore: release %s v%s", s.Name, s.Next) + } + return fmt.Sprintf("chore: release %d packages", len(summaries)) +} + +func buildPRBody(summaries []BumpSummary) string { + body := "## 📦 Pending releases\n\n" + body += "This PR was created by `stamp release-pr`. Merge it to apply the version bumps and trigger publishing.\n\n" + body += "| Project | Current | Next | Bump |\n" + body += "|---------|---------|------|------|\n" + for _, s := range summaries { + body += fmt.Sprintf("| %s | %s | %s | `%s` |\n", s.Name, s.Current, s.Next, s.Bump) + } + return body +} + +func upsertPR(ctx context.Context, opts Options, title, body string) (int, string, error) { + if isGitea() { + client, err := giteaClient.NewClient(opts.RepoSlug) + if err != nil { + return 0, "", err + } + num, url, err := client.UpsertPR(opts.ReleaseBranch, opts.BaseBranch, title, body) + if err != nil { + return 0, "", err + } + return int(num), url, nil + } + + client, err := ghClient.NewClient(opts.RepoSlug) + if err != nil { + return 0, "", err + } + return client.UpsertPR(ctx, opts.ReleaseBranch, opts.BaseBranch, title, body) +} + +func isGitea() bool { + return os.Getenv("GITEA_BASE_URL") != "" +} diff --git a/internal/semver/semver.go b/internal/semver/semver.go index 4283b9b..0c87d2f 100644 --- a/internal/semver/semver.go +++ b/internal/semver/semver.go @@ -9,6 +9,25 @@ import ( "git.thokra.dev/thokra/stamp/internal/changeset" ) +// PreReleaseBump converts a regular bump type to its pre-release equivalent, +// taking into account whether the current version is already a pre-release. +// If it is, the bump is always BumpPreRelease (just increment the pre number). +// Otherwise the highest regular bump is promoted to its pre-release variant. +func PreReleaseBump(currentVersion string, highest changeset.BumpType) changeset.BumpType { + v, err := goSemver.NewVersion(currentVersion) + if err == nil && v.Prerelease() != "" { + return changeset.BumpPreRelease + } + switch highest { + case changeset.BumpMajor: + return changeset.BumpPreMajor + case changeset.BumpMinor: + return changeset.BumpPreMinor + default: + return changeset.BumpPrePatch + } +} + // CurrentVersion parses a version string and returns the semver object. // It is exported so callers can inspect pre-release state without re-importing the semver library. func CurrentVersion(version string) (*goSemver.Version, error) {