From 4fade5c28e48cc18a4cdc462e2d69625a8eec695 Mon Sep 17 00:00:00 2001 From: fadeltd Date: Mon, 28 Sep 2026 00:42:23 +0700 Subject: [PATCH 1/3] Publish Homebrew cask to fadeltd/homebrew-tap and add landing page Add a homebrew_casks section so each release updates Casks/pdfunlock.rb in fadeltd/homebrew-tap over SSH with a deploy key. The cask removes the quarantine attribute with postflight_steps, since the binary is not notarized. Add a static landing page in docs/ for GitHub Pages. --- .github/workflows/release.yml | 1 + .goreleaser.yml | 36 ++++++++ CHANGELOG.md | 5 + README.md | 14 +++ docs/favicon.svg | 8 ++ docs/index.html | 169 ++++++++++++++++++++++++++++++++++ docs/sitemap.xml | 4 + 7 files changed, 237 insertions(+) create mode 100644 docs/favicon.svg create mode 100644 docs/index.html create mode 100644 docs/sitemap.xml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index afc287e..674fd08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }} ENABLE_UPX_LINUX: ${{ env.ENABLE_UPX_LINUX }} ENABLE_UPX_WINDOWS: ${{ env.ENABLE_UPX_WINDOWS }} ENABLE_UPX_DARWIN_INTEL: ${{ env.ENABLE_UPX_DARWIN_INTEL }} diff --git a/.goreleaser.yml b/.goreleaser.yml index 15071aa..f5dfdab 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -55,6 +55,42 @@ archives: checksum: name_template: 'checksums.txt' +# Homebrew cask in fadeltd/homebrew-tap: brew install --cask fadeltd/tap/pdfunlock +# Pushed over SSH with a deploy key (HOMEBREW_TAP_DEPLOY_KEY secret) +homebrew_casks: + - name: pdfunlock + directory: Casks + binaries: + - pdfunlock + description: "Unlock password-protected PDF files from the command-line" + homepage: "https://fadeltd.github.io/pdfunlock/" + commit_author: + name: github-actions[bot] + email: 41898282+github-actions[bot]@users.noreply.github.com + commit_msg_template: "pdfunlock {{ .Tag }}" + # Prereleases (e.g. v1.0.0-alpha.1) never reach the tap + skip_upload: auto + repository: + owner: fadeltd + name: homebrew-tap + branch: main + git: + url: git@github.com:fadeltd/homebrew-tap.git + private_key: "{{ .Env.HOMEBREW_TAP_DEPLOY_KEY }}" + # The binary is unsigned and not notarized, so drop the quarantine + # attribute to let it run straight after install. Homebrew 7 deprecates + # the `postflight` block that `hooks.post.install` generates, so write + # `postflight_steps` directly. GoReleaser puts custom_block at the top of + # the cask, so `brew style` reports Cask/StanzaOrder; that is cosmetic. + custom_block: | + postflight_steps do + on_macos do + run "/usr/bin/xattr", + args: ["-dr", "com.apple.quarantine", "{{ "{{" }}staged_path{{ "}}" }}/pdfunlock"], + writable_paths: ["pdfunlock"] + end + end + # snapshot configuration (uses defaults) changelog: diff --git a/CHANGELOG.md b/CHANGELOG.md index dd6ca9d..f118f10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- Install on macOS with Homebrew: `brew install --cask fadeltd/tap/pdfunlock` + ## [1.0.0] - 2025-09-01 ### Added diff --git a/README.md b/README.md index 34b8818..dc306b4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 🔓 **Free Open Source PDF Unlocker** - A powerful CLI command-line tool to unlock password-protected PDF files instantly. Perfect for batch PDF unlock operations, this free PDF password remover supports both single file processing and bulk directory processing. +🌐 **Website:** [fadeltd.github.io/pdfunlock](https://fadeltd.github.io/pdfunlock/) + **Keywords:** unlock pdf free, pdf unlocker, open source pdf unlock, batch pdf unlock, free pdf password remover, decrypt pdf files, pdf security removal ## Features - Why Choose This Free PDF Unlocker? @@ -38,6 +40,18 @@ Unlike web-based PDF unlock services, this tool runs entirely on your computer, ## Installation +### Homebrew (macOS) + +```bash +brew install --cask fadeltd/tap/pdfunlock +``` + +To update: + +```bash +brew upgrade --cask pdfunlock +``` + ### Download Pre-built Binaries Download the latest release for your platform from the [Releases page](https://github.com/fadeltd/pdfunlock/releases). diff --git a/docs/favicon.svg b/docs/favicon.svg new file mode 100644 index 0000000..b0e8cf9 --- /dev/null +++ b/docs/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..742b5d7 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,169 @@ + + + + + +PDFUnlock — Free PDF Password Remover for the Command Line + + + + + + + + + + + + + + +
+
+ +

PDFUnlock

+

Remove the password from PDFs you can already open, so you never have to type it again. Unlock one file or a whole folder at once, offline, on macOS, Linux and Windows.

+ +
+ +
+

Why PDFUnlock?

+
+

Stays on your computer

No upload, no web service. Bank statements, payslips and contracts never leave your device.

+

Whole folders at once

Point it at a directory and every PDF inside is unlocked. You type the password once per folder.

+

Keeps your originals

Unlocked copies are saved next to the originals with an _unlocked suffix. PDFs without a password are copied unchanged.

+

Retries on a wrong password

Mistyped it? PDFUnlock asks again, up to three times, before giving up on a file.

+
+
+ +
+

Install

+

Homebrew (macOS)

+
brew install --cask fadeltd/tap/pdfunlock
+

Update later with brew upgrade --cask pdfunlock.

+ +

Pre-built binaries

+

Download the archive for your platform from the latest release, extract it, and put pdfunlock somewhere on your PATH. Builds are available for macOS (Apple Silicon, Intel), Linux (x86_64, ARM64) and Windows (x86_64).

+ +

Build from source

+
git clone https://github.com/fadeltd/pdfunlock.git
+cd pdfunlock
+make build
+
+ +
+

Usage

+

PDFUnlock prompts for the password without echoing it to the screen.

+

One file

+
# Creates statement_unlocked.pdf
+pdfunlock statement.pdf
+
+# Choose the output file
+pdfunlock statement.pdf statement-open.pdf
+pdfunlock -in statement.pdf -out statement-open.pdf
+
+# The password you have is the owner (permissions) password
+pdfunlock statement.pdf --owner
+

A whole folder

+
# Unlock every PDF in the folder
+pdfunlock ~/Documents/statements
+pdfunlock -dir ~/Documents/statements
+
+ +
+

FAQ

+
Can it open a PDF if I don't know the password?

No. PDFUnlock doesn't guess or crack passwords. You enter the password once, and it saves a copy of the PDF with the password removed.

+
Are my PDFs uploaded anywhere?

No. PDFUnlock runs entirely on your computer and never sends files or passwords over the network.

+
What does --owner do?

PDFs can have a user password (needed to open the file) and an owner password (which controls printing, copying and editing). By default PDFUnlock treats what you type as the user password; pass --owner if you have the owner password instead.

+
Is it free?

Yes. PDFUnlock is open source under the MIT license.

+
Which platforms does it run on?

macOS (Apple Silicon and Intel), Linux (x86_64 and ARM64) and Windows (x86_64).

+
+ + +
+ + diff --git a/docs/sitemap.xml b/docs/sitemap.xml new file mode 100644 index 0000000..17e89da --- /dev/null +++ b/docs/sitemap.xml @@ -0,0 +1,4 @@ + + + https://fadeltd.github.io/pdfunlock/ + From 2ea5f3ef335625b11e3eefd7ef5a49c45a86a15d Mon Sep 17 00:00:00 2001 From: fadeltd Date: Mon, 28 Sep 2026 00:47:49 +0700 Subject: [PATCH 2/3] Pin Go 1.26.8, fix Windows archive name in release notes, add 1.0.1 to changelog Ignore tmp/ for scratch files. --- .gitignore | 4 +++- .go-version | 2 +- .goreleaser.yml | 2 +- CHANGELOG.md | 14 ++++++++++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index a313e25..07ee8b3 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,6 @@ bin/ *.log # Environment variables -.env \ No newline at end of file +.env +# Scratch files +tmp/ diff --git a/.go-version b/.go-version index ea0928c..25691b4 100644 --- a/.go-version +++ b/.go-version @@ -1 +1 @@ -1.26.4 +1.26.8 diff --git a/.goreleaser.yml b/.goreleaser.yml index f5dfdab..652eae0 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -130,7 +130,7 @@ release: - **Linux (x86_64)**: `pdfunlock_Linux_x86_64.tar.gz` - **Linux (ARM64)**: `pdfunlock_Linux_arm64.tar.gz` - - **Windows (x86_64)**: `pdfunlock_Windows_x86_64.zip` + - **Windows (x86_64)**: `pdfunlock_Windows_x86_64.tar.gz` - **macOS (Intel)**: `pdfunlock_Darwin_x86_64.tar.gz` - **macOS (Apple Silicon)**: `pdfunlock_Darwin_arm64.tar.gz` diff --git a/CHANGELOG.md b/CHANGELOG.md index f118f10..3d58eee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Install on macOS with Homebrew: `brew install --cask fadeltd/tap/pdfunlock` +- Project website at https://fadeltd.github.io/pdfunlock/ + +### Changed +- Pin Go 1.26.8 for local builds (was 1.26.4) + +### Fixed +- Release notes now list the Windows download as `pdfunlock_Windows_x86_64.tar.gz` + +## [1.0.1] - 2026-07-03 + +### Changed +- Windows release archive is `.tar.gz` instead of `.zip` +- Release archives no longer wrap files in a directory; the binary is at the top level +- Update GitHub Actions to `actions/checkout@v4.2.0` and `actions/setup-go@v5` ## [1.0.0] - 2025-09-01 From 3413065977ccd0a672f15bd60f8510220594135e Mon Sep 17 00:00:00 2001 From: fadeltd Date: Mon, 28 Sep 2026 00:55:41 +0700 Subject: [PATCH 3/3] Release from the changelog on merge, as in xiaomi-debloater Merging to main now cuts the next version from [Unreleased] in CHANGELOG.md (Breaking -> major, Added -> minor, otherwise patch), commits the dated section, tags it and dispatches Publish. Publish (the old tag-triggered release workflow) runs GoReleaser on that tag with the changelog section as the release notes. CI requires a changelog entry on PRs that change the tool and runs gofmt, vet, tests, build and goreleaser check. --- .github/workflows/ci.yml | 98 ++++++++++++++++ .github/workflows/publish.yml | 102 +++++++++++++++++ .github/workflows/release.yml | 111 +++++++++++------- Makefile | 10 +- README.md | 33 +++--- scripts/release/changelog.go | 182 ++++++++++++++++++++++++++++++ scripts/release/changelog_test.go | 120 ++++++++++++++++++++ scripts/release/main.go | 97 ++++++++++++++++ 8 files changed, 696 insertions(+), 57 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 scripts/release/changelog.go create mode 100644 scripts/release/changelog_test.go create mode 100644 scripts/release/main.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1372eb2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,98 @@ +name: CI + +on: + pull_request: + # labeled/unlabeled so adding or removing skip-changelog re-runs the check. + types: [opened, synchronize, reopened, labeled, unlabeled] + push: + branches: [main] + +# Cancel superseded runs on the same ref: only the latest push matters. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + changelog: + name: Changelog entry + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v7 + with: + go-version-file: .go-version + + # Every PR that changes the tool or its packages needs an entry under + # [Unreleased], because that section is what the next release is cut + # from. Docs, the website, CI, release tooling and local build files + # ship without a release, so they are exempt, and so is a PR labelled + # skip-changelog. + - name: Require a CHANGELOG.md entry + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + SKIP: ${{ contains(github.event.pull_request.labels.*.name, 'skip-changelog') }} + run: | + set -euo pipefail + if [ "$SKIP" = "true" ]; then + echo "::notice::skip-changelog label set; not checking CHANGELOG.md." + exit 0 + fi + + files=$(gh pr diff "$PR" --name-only) + exempt='^(LICENSE|\.gitignore|Makefile|env\.sample|[^/]*\.md|docs/.*|\.github/.*|scripts/.*)$' + needs=$(echo "$files" | grep -Ev "$exempt" || true) + if [ -z "$needs" ]; then + echo "::notice::Only exempt files changed; no changelog entry needed." + exit 0 + fi + + if ! echo "$files" | grep -qx 'CHANGELOG.md'; then + echo "These files change the tool or its packages:" + while IFS= read -r f; do echo " $f"; done <<<"$needs" + echo "::error file=CHANGELOG.md::Add an entry under ## [Unreleased] in CHANGELOG.md, or label the PR skip-changelog if it should not be released." + exit 1 + fi + + preview=$(go run ./scripts/release -dry-run) + echo "$preview" + if ! grep -q '^bump' <<<"$preview"; then + echo "::error file=CHANGELOG.md::CHANGELOG.md changed, but ## [Unreleased] has no ### section with bullets under it." + exit 1 + fi + + verify: + name: Test and build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v7 + with: + go-version-file: .go-version + + - name: Check formatting + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "::error::Run gofmt -w on: $unformatted" + exit 1 + fi + + - name: Vet, test and build + run: | + go vet ./... + go test ./... + go build -o /dev/null . + + - name: Check the GoReleaser config + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: "~> v2" + args: check diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..8364884 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,102 @@ +name: Publish + +# Builds a TAG, never a branch: whatever shipped is always reachable by version. +# Started by the Release workflow after it tags a merge to main. To publish a +# hand-pushed tag, run it from the Actions tab with that tag (or use +# `make release VERSION=vX.Y.Z`, which pushes the tag and starts this). +# +# GoReleaser builds the archives, creates the GitHub release with the notes of +# that version from CHANGELOG.md, and pushes the Homebrew cask to +# fadeltd/homebrew-tap (skipped for prereleases). +on: + workflow_dispatch: + inputs: + tag: + description: 'Tag to publish, e.g. v1.1.0' + required: true + type: string + +concurrency: + group: publish-${{ inputs.tag }} + cancel-in-progress: false + +permissions: + contents: write + +env: + TAG: ${{ inputs.tag }} + # Per-platform UPX compression settings + # Can be overridden in repository settings -> Secrets and variables -> Actions -> Variables + ENABLE_UPX_LINUX: ${{ vars.ENABLE_UPX_LINUX || 'false' }} + ENABLE_UPX_WINDOWS: ${{ vars.ENABLE_UPX_WINDOWS || 'false' }} + ENABLE_UPX_DARWIN_INTEL: ${{ vars.ENABLE_UPX_DARWIN_INTEL || 'false' }} + ENABLE_UPX_DARWIN_ARM: ${{ vars.ENABLE_UPX_DARWIN_ARM || 'false' }} + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag }} + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: .go-version + + - name: Install UPX + if: env.ENABLE_UPX_LINUX == 'true' || env.ENABLE_UPX_WINDOWS == 'true' || env.ENABLE_UPX_DARWIN_INTEL == 'true' || env.ENABLE_UPX_DARWIN_ARM == 'true' + run: | + sudo apt-get update + sudo apt-get install -y upx-ucl + + # The notes come from the version's CHANGELOG.md section. A hand-pushed + # prerelease has no section, so GoReleaser generates notes from commits. + # + # The previous tag is set explicitly because older tags are not ancestors + # of main (history was rewritten after v1.0.1), so GoReleaser cannot find + # it with git describe. It is the newest stable tag below this one; a + # prerelease leaves it to GoReleaser, since sort -V ranks v1.0.0-rc1 + # above v1.0.0. + - name: Prepare notes and previous tag + id: prep + run: | + set -euo pipefail + if go run ./scripts/release -notes "${TAG#v}" > "$RUNNER_TEMP/notes.md"; then + echo "args=--release-notes $RUNNER_TEMP/notes.md" >> "$GITHUB_OUTPUT" + else + echo "::notice::No CHANGELOG.md section for ${TAG}; GoReleaser will generate the notes." + fi + previous="" + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + previous=$( { git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true; echo "$TAG"; } \ + | sort -uV | grep -B1 -x "$TAG" | grep -vx "$TAG" || true) + fi + echo "previous=${previous}" >> "$GITHUB_OUTPUT" + echo "Publishing ${TAG}, previous ${previous:-none}" >> "$GITHUB_STEP_SUMMARY" + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean ${{ steps.prep.outputs.args }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }} + GORELEASER_CURRENT_TAG: ${{ inputs.tag }} + GORELEASER_PREVIOUS_TAG: ${{ steps.prep.outputs.previous }} + ENABLE_UPX_LINUX: ${{ env.ENABLE_UPX_LINUX }} + ENABLE_UPX_WINDOWS: ${{ env.ENABLE_UPX_WINDOWS }} + ENABLE_UPX_DARWIN_INTEL: ${{ env.ENABLE_UPX_DARWIN_INTEL }} + ENABLE_UPX_DARWIN_ARM: ${{ env.ENABLE_UPX_DARWIN_ARM }} + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: release-artifacts + path: dist/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 674fd08..2cf889c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,58 +1,93 @@ name: Release +# Merging to main does not publish directly. It derives the next version from +# the [Unreleased] section of CHANGELOG.md, rewrites the changelog, commits it +# back to main, pushes a tag and starts Publish, which builds the tag and ships +# it. +# +# A merge with an empty [Unreleased] releases nothing, so merging and releasing +# stay separate decisions. on: push: - tags: - - 'v*' + branches: [main] + workflow_dispatch: + +concurrency: + group: release + cancel-in-progress: false permissions: contents: write - -env: - # Per-platform UPX compression settings - # Can be overridden in repository settings -> Secrets and variables -> Actions -> Variables - ENABLE_UPX_LINUX: ${{ vars.ENABLE_UPX_LINUX || 'false' }} - ENABLE_UPX_WINDOWS: ${{ vars.ENABLE_UPX_WINDOWS || 'false' }} - ENABLE_UPX_DARWIN_INTEL: ${{ vars.ENABLE_UPX_DARWIN_INTEL || 'false' }} - ENABLE_UPX_DARWIN_ARM: ${{ vars.ENABLE_UPX_DARWIN_ARM || 'false' }} + actions: write jobs: - goreleaser: + release: + name: Cut a release from the changelog runs-on: ubuntu-latest + # Never react to our own release commit. + if: "!contains(github.event.head_commit.message, '[skip ci]')" steps: - - name: Checkout - uses: actions/checkout@v4.2.0 + - uses: actions/checkout@v7 with: + # Check out the BRANCH TIP, not the commit that triggered the run. + # On a re-run the triggering sha is stale, and committing on top of it + # produces a non-fast-forward push that is rejected. Combined with the + # changelog logic being idempotent (an empty [Unreleased] releases + # nothing) this makes a re-run safe rather than broken. + ref: main fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} - - name: Set up Go - uses: actions/setup-go@v5 + - uses: actions/setup-go@v7 with: - go-version: '1.26' + go-version-file: .go-version + + - id: cut + name: Derive the version from CHANGELOG.md + run: go run ./scripts/release - - name: Install UPX - if: env.ENABLE_UPX_LINUX == 'true' || env.ENABLE_UPX_WINDOWS == 'true' || env.ENABLE_UPX_DARWIN_INTEL == 'true' || env.ENABLE_UPX_DARWIN_ARM == 'true' + - name: Commit the changelog and tag + if: steps.cut.outputs.released == 'true' + env: + TAG: ${{ steps.cut.outputs.tag }} run: | - sudo apt-get update - sudo apt-get install -y upx-ucl + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v7 - with: - distribution: goreleaser - version: "~> v2" - args: release --clean + # Each push is guarded, so a partially-completed earlier attempt can + # be resumed rather than blocking every retry. + if git diff --quiet -- CHANGELOG.md; then + echo "::notice::Changelog already committed." + else + git add CHANGELOG.md + # [skip ci] so this push cannot re-enter this workflow when a PAT + # is in use: with a PAT, pushes DO trigger workflows. + git commit -m "Release ${TAG} [skip ci]" + git push origin HEAD:main + fi + + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "::notice::${TAG} already exists on the remote." + else + git tag -a "${TAG}" -m "Release ${TAG}" + git push origin "${TAG}" + fi + + # Always dispatch, never rely on the tag event. Whether a tag pushed by + # automation triggers `on: push: tags` depends on which credential pushed + # it, and a publish that silently does not happen looks exactly like one + # that succeeded. + - name: Start Publish + if: steps.cut.outputs.released == 'true' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} - HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }} - ENABLE_UPX_LINUX: ${{ env.ENABLE_UPX_LINUX }} - ENABLE_UPX_WINDOWS: ${{ env.ENABLE_UPX_WINDOWS }} - ENABLE_UPX_DARWIN_INTEL: ${{ env.ENABLE_UPX_DARWIN_INTEL }} - ENABLE_UPX_DARWIN_ARM: ${{ env.ENABLE_UPX_DARWIN_ARM }} - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: release-artifacts - path: dist/ \ No newline at end of file + run: gh workflow run publish.yml --ref main -f tag="${{ steps.cut.outputs.tag }}" + + - name: Summary + run: | + if [ "${{ steps.cut.outputs.released }}" = "true" ]; then + echo "Released ${{ steps.cut.outputs.tag }}; Publish started." >> "$GITHUB_STEP_SUMMARY" + else + echo "No [Unreleased] entries: nothing released." >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/Makefile b/Makefile index f3b5772..df931c1 100644 --- a/Makefile +++ b/Makefile @@ -113,16 +113,18 @@ tag: @echo "Tag $(VERSION) created successfully" @echo "To push the tag and trigger release, run: make release VERSION=$(VERSION)" -# Push tag to trigger GitHub Actions release +# Push a hand-made tag and start the Publish workflow for it. Normally not +# needed: merging to main releases from the [Unreleased] changelog section. # Usage: make release VERSION=v1.0.0 release: @if [ -z "$(VERSION)" ]; then \ echo "Error: VERSION is required. Usage: make release VERSION=v1.0.0"; \ exit 1; \ fi - @echo "Pushing tag $(VERSION) to trigger release..." + @echo "Pushing tag $(VERSION) and starting Publish..." @git push origin $(VERSION) - @echo "Release triggered! Check GitHub Actions for build status." + @gh workflow run publish.yml --ref main -f tag=$(VERSION) + @echo "Publish started! Check GitHub Actions for build status." @echo "Release will be available at: https://github.com/fadeltd/pdfunlock/releases/tag/$(VERSION)" # Create and push tag in one command @@ -140,7 +142,7 @@ help: @echo " uninstall - Remove binary from system" @echo " build-all - Build for multiple platforms" @echo " tag - Create a new tag (requires VERSION=vX.Y.Z)" - @echo " release - Push tag to trigger release (requires VERSION=vX.Y.Z)" + @echo " release - Push tag and start Publish (requires VERSION=vX.Y.Z)" @echo " tag-release - Create and push tag in one command (requires VERSION=vX.Y.Z)" @echo " help - Show this help message" @echo "" diff --git a/README.md b/README.md index dc306b4..9fc703f 100644 --- a/README.md +++ b/README.md @@ -189,24 +189,27 @@ This project uses GitHub Actions with GoReleaser for automated cross-platform re **Release Process:** -*Using Makefile (Recommended):* -```bash -# Create and push tag in one command -make tag-release VERSION=v1.0.0 +You never pick a version number or write a release date. Releases are cut from [`CHANGELOG.md`](CHANGELOG.md): -# Or step by step: -make tag VERSION=v1.0.0 -make release VERSION=v1.0.0 -``` +1. In your pull request, add an entry under `## [Unreleased]` using a [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) heading. CI fails a PR that changes the tool without one. Changes to Markdown files, `docs/`, `.github/`, `scripts/`, `Makefile` and `env.sample` are exempt, and the `skip-changelog` label skips the check. + ```markdown + ## [Unreleased] -*Manual process:* -```bash -# Create and push a git tag -git tag v1.0.0 && git push origin v1.0.0 -``` + ### Fixed + - The thing you fixed + ``` +2. Merge to `main`. The Release workflow picks the next version from the headings: + - `### Breaking` → major + - `### Added` → minor + - anything else → patch +3. It moves the entries into a dated `## [x.y.z]` section, commits that as `Release vx.y.z`, and pushes the tag. +4. It then starts Publish. GoReleaser builds the binaries, creates the GitHub release with those notes, and updates the Homebrew cask. + +A merge with an empty `[Unreleased]` releases nothing. Preview the next release locally with `go run ./scripts/release -dry-run`. + +To publish a hand-made tag instead (for example a prerelease such as `v1.2.0-rc.1`), run `make tag-release VERSION=v1.2.0-rc.1`, or run Publish from the Actions tab. Prereleases never reach the Homebrew tap. -2. GitHub Actions automatically builds and releases binaries -3. Binaries are available on the [Releases page](https://github.com/fadeltd/pdfunlock/releases) +Binaries are available on the [Releases page](https://github.com/fadeltd/pdfunlock/releases). **UPX Compression:** UPX compression is automatically applied during cross-platform builds and can be configured via environment variables: diff --git a/scripts/release/changelog.go b/scripts/release/changelog.go new file mode 100644 index 0000000..5cf85bd --- /dev/null +++ b/scripts/release/changelog.go @@ -0,0 +1,182 @@ +package main + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// Release logic, derived from CHANGELOG.md. +// +// Pure and tested, because a mistake here silently ships the wrong version or +// loses release notes, and neither is obvious from a green workflow run. + +type Bump string + +const ( + BumpMajor Bump = "major" + BumpMinor Bump = "minor" + BumpPatch Bump = "patch" + BumpNone Bump = "none" +) + +var ( + unreleasedHeading = regexp.MustCompile(`(?m)^## \[Unreleased\][ \t]*$`) + releaseHeading = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\]`) + sectionHeading = regexp.MustCompile(`(?m)^### (.+?)[ \t]*$`) + bullet = regexp.MustCompile(`(?m)^[-*] `) + semver = regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)$`) +) + +// Unreleased is the block under "## [Unreleased]". +type Unreleased struct { + Body string // section bodies, verbatim + Sections []string // heading names found, e.g. [Added Fixed] + HasContent bool +} + +// ParseUnreleased extracts the Unreleased block, ignoring filler like "Nothing yet." +func ParseUnreleased(changelog string) Unreleased { + loc := unreleasedHeading.FindStringIndex(changelog) + if loc == nil { + return Unreleased{} + } + rest := changelog[loc[1]:] + if next := releaseHeading.FindStringIndex(rest); next != nil { + rest = rest[:next[0]] + } + body := strings.TrimSpace(rest) + + var sections []string + for _, m := range sectionHeading.FindAllStringSubmatch(body, -1) { + sections = append(sections, strings.TrimSpace(m[1])) + } + + // A section heading with no bullets under it is not content. + return Unreleased{ + Body: body, + Sections: sections, + HasContent: len(sections) > 0 && bullet.MatchString(body), + } +} + +// CurrentVersion is the newest released version: the first "## [x.y.z]" +// heading, since Keep a Changelog lists releases newest first. +func CurrentVersion(changelog string) (string, error) { + m := releaseHeading.FindStringSubmatch(changelog) + if m == nil { + return "", fmt.Errorf("no released version in the changelog") + } + return m[1], nil +} + +// DetermineBump maps Keep a Changelog sections to a semver bump. +// +// Breaking must be explicit: inferring a major from Removed would let a tidy-up +// silently become a 2.0, which is exactly the surprise this should avoid. +func DetermineBump(u Unreleased) Bump { + if !u.HasContent { + return BumpNone + } + bump := BumpPatch + for _, s := range u.Sections { + switch strings.ToLower(s) { + case "breaking", "breaking changes": + return BumpMajor + case "added": + bump = BumpMinor + } + } + return bump +} + +func parseVersion(version string) ([3]int, error) { + m := semver.FindStringSubmatch(strings.TrimSpace(version)) + if m == nil { + return [3]int{}, fmt.Errorf("not a semver version: %q", version) + } + var v [3]int + for i := range v { + v[i], _ = strconv.Atoi(m[i+1]) + } + return v, nil +} + +// NextVersion applies bump to current. +func NextVersion(current string, bump Bump) (string, error) { + v, err := parseVersion(current) + if err != nil { + return "", err + } + switch bump { + case BumpMajor: + // A breaking change pre-1.0 is a minor bump by convention. + if v[0] == 0 { + return fmt.Sprintf("0.%d.0", v[1]+1), nil + } + return fmt.Sprintf("%d.0.0", v[0]+1), nil + case BumpMinor: + return fmt.Sprintf("%d.%d.0", v[0], v[1]+1), nil + case BumpPatch: + return fmt.Sprintf("%d.%d.%d", v[0], v[1], v[2]+1), nil + } + return current, nil +} + +// Release is the outcome of ApplyRelease. +type Release struct { + Changelog string + Version string + Bump Bump + Notes string +} + +var excessBlankLines = regexp.MustCompile(`\n{4,}`) + +// ApplyRelease moves the Unreleased block into a dated release section and +// leaves a fresh empty Unreleased behind. It returns nil when there is nothing +// to release, and writes nothing. +func ApplyRelease(changelog, today string) (*Release, error) { + u := ParseUnreleased(changelog) + bump := DetermineBump(u) + if bump == BumpNone { + return nil, nil + } + current, err := CurrentVersion(changelog) + if err != nil { + return nil, err + } + version, err := NextVersion(current, bump) + if err != nil { + return nil, err + } + + loc := unreleasedHeading.FindStringIndex(changelog) + head, rest := changelog[:loc[1]], changelog[loc[1]:] + tail := "" + if next := releaseHeading.FindStringIndex(rest); next != nil { + tail = rest[next[0]:] + } + + rebuilt := head + "\n\nNothing yet.\n\n" + + fmt.Sprintf("## [%s] - %s\n\n", version, today) + + u.Body + "\n\n" + tail + rebuilt = strings.TrimRight(excessBlankLines.ReplaceAllString(rebuilt, "\n\n\n"), "\n") + "\n" + + return &Release{Changelog: rebuilt, Version: version, Bump: bump, Notes: u.Body}, nil +} + +// NotesFor returns the body of the "## [version]" section, or "" if absent. +func NotesFor(changelog, version string) string { + heading := regexp.MustCompile(`(?m)^## \[` + regexp.QuoteMeta(version) + `\].*$`) + loc := heading.FindStringIndex(changelog) + if loc == nil { + return "" + } + rest := changelog[loc[1]:] + if next := releaseHeading.FindStringIndex(rest); next != nil { + rest = rest[:next[0]] + } + return strings.TrimSpace(rest) +} diff --git a/scripts/release/changelog_test.go b/scripts/release/changelog_test.go new file mode 100644 index 0000000..ebf3917 --- /dev/null +++ b/scripts/release/changelog_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "strings" + "testing" +) + +const header = "# Changelog\n\nIntro.\n\n" + +func TestParseUnreleasedIgnoresFiller(t *testing.T) { + u := ParseUnreleased(header + "## [Unreleased]\n\nNothing yet.\n\n## [1.0.0] - 2026-09-27\n\n### Added\n- First\n") + if u.HasContent { + t.Fatalf("filler counted as content: %+v", u) + } +} + +func TestParseUnreleasedHeadingWithoutBullets(t *testing.T) { + u := ParseUnreleased(header + "## [Unreleased]\n\n### Fixed\n\n## [1.0.0] - 2026-09-27\n") + if u.HasContent { + t.Fatal("a heading with no bullets is not content") + } +} + +func TestParseUnreleasedStopsAtNextRelease(t *testing.T) { + u := ParseUnreleased(header + "## [Unreleased]\n\n### Fixed\n- New\n\n## [1.0.0] - 2026-09-27\n\n### Added\n- Old\n") + if !u.HasContent || strings.Contains(u.Body, "Old") || len(u.Sections) != 1 { + t.Fatalf("unexpected parse: %+v", u) + } +} + +func TestParseUnreleasedMissing(t *testing.T) { + if u := ParseUnreleased(header); u.HasContent { + t.Fatal("no Unreleased heading should mean no content") + } +} + +func TestCurrentVersion(t *testing.T) { + cl := header + "## [Unreleased]\n\n### Fixed\n- a\n\n## [1.0.1] - 2026-07-03\n\n## [1.0.0] - 2025-09-01\n" + if v, err := CurrentVersion(cl); err != nil || v != "1.0.1" { + t.Fatalf("CurrentVersion = %q, %v; want 1.0.1", v, err) + } + if _, err := CurrentVersion(header + "## [Unreleased]\n"); err == nil { + t.Error("expected an error without a released version") + } +} + +func TestDetermineBump(t *testing.T) { + cases := map[string]Bump{ + "### Fixed\n- a": BumpPatch, + "### Changed\n- a": BumpPatch, + "### Removed\n- a": BumpPatch, + "### Fixed\n- a\n\n### Added\n- b": BumpMinor, + "### Added\n- a\n\n### Breaking\n- b": BumpMajor, + "### Breaking Changes\n- a": BumpMajor, + "Nothing yet.": BumpNone, + } + for body, want := range cases { + got := DetermineBump(ParseUnreleased("## [Unreleased]\n\n" + body + "\n")) + if got != want { + t.Errorf("%q: got %s, want %s", body, got, want) + } + } +} + +func TestNextVersion(t *testing.T) { + cases := []struct { + current string + bump Bump + want string + }{ + {"1.0.0", BumpPatch, "1.0.1"}, + {"1.0.3", BumpMinor, "1.1.0"}, + {"1.4.2", BumpMajor, "2.0.0"}, + {"0.3.1", BumpMajor, "0.4.0"}, + {"1.2.3", BumpNone, "1.2.3"}, + } + for _, c := range cases { + got, err := NextVersion(c.current, c.bump) + if err != nil || got != c.want { + t.Errorf("NextVersion(%s, %s) = %s, %v; want %s", c.current, c.bump, got, err, c.want) + } + } + if _, err := NextVersion("v1.0", BumpPatch); err == nil { + t.Error("expected an error for a non-semver version") + } +} + +func TestApplyRelease(t *testing.T) { + in := header + "## [Unreleased]\n\n### Added\n- Homebrew cask\n\n## [1.0.1] - 2026-07-03\n\n### Changed\n- Tarballs\n" + rel, err := ApplyRelease(in, "2026-10-01") + if err != nil || rel == nil { + t.Fatalf("ApplyRelease: %v, %v", rel, err) + } + want := header + "## [Unreleased]\n\nNothing yet.\n\n## [1.1.0] - 2026-10-01\n\n### Added\n- Homebrew cask\n\n## [1.0.1] - 2026-07-03\n\n### Changed\n- Tarballs\n" + if rel.Changelog != want { + t.Fatalf("changelog:\n%s\nwant:\n%s", rel.Changelog, want) + } + if rel.Version != "1.1.0" || rel.Notes != "### Added\n- Homebrew cask" { + t.Fatalf("unexpected release: %+v", rel) + } + + // Releasing the result again is a no-op, which is what makes re-runs safe. + again, err := ApplyRelease(rel.Changelog, "2026-10-01") + if err != nil || again != nil { + t.Fatalf("second release should be nil, got %+v, %v", again, err) + } +} + +func TestNotesFor(t *testing.T) { + cl := header + "## [Unreleased]\n\nNothing yet.\n\n## [1.1.0] - 2026-10-01\n\n### Added\n- B\n\n## [1.0.0] - 2026-09-27\n\n### Added\n- A\n" + if got := NotesFor(cl, "1.1.0"); got != "### Added\n- B" { + t.Errorf("1.1.0 notes = %q", got) + } + if got := NotesFor(cl, "1.0.0"); got != "### Added\n- A" { + t.Errorf("1.0.0 notes = %q", got) + } + if got := NotesFor(cl, "9.9.9"); got != "" { + t.Errorf("missing version notes = %q", got) + } +} diff --git a/scripts/release/main.go b/scripts/release/main.go new file mode 100644 index 0000000..910dfd3 --- /dev/null +++ b/scripts/release/main.go @@ -0,0 +1,97 @@ +// Command release cuts a release from CHANGELOG.md. +// +// It reads the Unreleased section, decides the semver bump from its headings, +// rewrites CHANGELOG.md, and reports the outcome for the workflow to consume. +// The current version is the newest released section of the changelog. It +// writes the changelog but never touches git: the workflow owns committing, +// tagging and pushing, so this stays runnable locally as a dry run. +// +// go run ./scripts/release -dry-run # preview the next release +// go run ./scripts/release # apply it +// go run ./scripts/release -notes 1.1.0 # print the notes of a released version +package main + +import ( + "flag" + "fmt" + "os" + "time" +) + +const changelogPath = "CHANGELOG.md" + +func main() { + dryRun := flag.Bool("dry-run", false, "print what would be released without writing files") + notes := flag.String("notes", "", "print the changelog notes of this version and exit") + flag.Parse() + + if err := run(*dryRun, *notes); err != nil { + fmt.Fprintln(os.Stderr, "release:", err) + os.Exit(1) + } +} + +func run(dryRun bool, notesVersion string) error { + changelog, err := os.ReadFile(changelogPath) + if err != nil { + return err + } + + if notesVersion != "" { + n := NotesFor(string(changelog), notesVersion) + if n == "" { + return fmt.Errorf("no [%s] section in %s", notesVersion, changelogPath) + } + fmt.Println(n) + return nil + } + + current, err := CurrentVersion(string(changelog)) + if err != nil { + return err + } + today := time.Now().UTC().Format("2006-01-02") + + rel, err := ApplyRelease(string(changelog), today) + if err != nil { + return err + } + if rel == nil { + fmt.Println("Nothing under [Unreleased]: no release.") + return emit("released", "false") + } + + fmt.Printf("bump : %s\nversion : %s -> %s\ntag : v%s\n", rel.Bump, current, rel.Version, rel.Version) + if dryRun { + fmt.Printf("\n--- notes ---\n%s\n\n(dry run: no files written)\n", rel.Notes) + return nil + } + + if err := os.WriteFile(changelogPath, []byte(rel.Changelog), 0o644); err != nil { + return err + } + + for k, v := range map[string]string{"released": "true", "version": rel.Version, "tag": "v" + rel.Version} { + if err := emit(k, v); err != nil { + return err + } + } + + fmt.Printf("\n%s updated.\n", changelogPath) + return nil +} + +// emit sets a single-line step output when running under GitHub Actions. +func emit(key, value string) error { + out := os.Getenv("GITHUB_OUTPUT") + if out == "" { + return nil + } + f, err := os.OpenFile(out, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644) + if err != nil { + return err + } + defer f.Close() + _, err = fmt.Fprintln(f, key+"="+value) + return err +}