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 afc287e..2cf889c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,57 +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 }} - 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/.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 15071aa..652eae0 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: @@ -94,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 dd6ca9d..3d58eee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ 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` +- 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 ### Added 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 34b8818..9fc703f 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). @@ -175,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/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 @@ + + +
+ + +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.
+ +No upload, no web service. Bank statements, payslips and contracts never leave your device.
Point it at a directory and every PDF inside is unlocked. You type the password once per folder.
Unlocked copies are saved next to the originals with an _unlocked suffix. PDFs without a password are copied unchanged.
Mistyped it? PDFUnlock asks again, up to three times, before giving up on a file.
brew install --cask fadeltd/tap/pdfunlock
+ Update later with brew upgrade --cask pdfunlock.
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).
git clone https://github.com/fadeltd/pdfunlock.git
+cd pdfunlock
+make build
+ PDFUnlock prompts for the password without echoing it to the screen.
+# 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
+ # Unlock every PDF in the folder
+pdfunlock ~/Documents/statements
+pdfunlock -dir ~/Documents/statements
+ 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.
No. PDFUnlock runs entirely on your computer and never sends files or passwords over the network.
--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.
Yes. PDFUnlock is open source under the MIT license.
macOS (Apple Silicon and Intel), Linux (x86_64 and ARM64) and Windows (x86_64).