Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
102 changes: 102 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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/
110 changes: 73 additions & 37 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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/
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,6 @@ bin/
*.log

# Environment variables
.env
.env
# Scratch files
tmp/
2 changes: 1 addition & 1 deletion .go-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.26.4
1.26.8
38 changes: 37 additions & 1 deletion .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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`

Expand Down
Loading
Loading