From 3af54677300339207e5a01f05e2124b8815ea130 Mon Sep 17 00:00:00 2001 From: Hiroshi IKEGAMI Date: Sun, 25 Oct 2026 17:53:01 +0900 Subject: [PATCH 1/6] build: adopt Hephaestus and modernize development workflows Release git-parallel with Hephaestus v1.1.0 as one noarch archive (git-parallel, git-parallel.1, README.md), a checksum file, provenance attestations and a Homebrew tap pull request for a new git-parallel formula. Add CI (ShellCheck, actionlint, Renovate validation, behavior tests on bash 3.2 and bash 5 on Linux and macOS, Hephaestus snapshot, Homebrew E2E), a tag-triggered release workflow split into read-only build and environment-gated publish jobs, Renovate, Makefile targets and release documentation. Existing install/uninstall targets are unchanged. Fix git-parallel bugs found by the new tests: - run on the bash 3.2 that ships with macOS (named file descriptors, `exec {FD}<`, need bash 4.1; job output now goes to temporary files) - handle repository paths containing spaces - accept --targets as documented in the help (--target still works) - reject --jobs 0, which looped forever - stop child processes on HUP/INT/QUIT/TERM on macOS too (pgrep -P instead of GNU-only `ps --ppid`) and remove temporary files - drop the perl dependency from repository discovery Claude-Session: https://claude.ai/code/session_01RbGbwRHvCEhsKLZfbvbbRg --- .github/workflows/ci.yml | 168 +++++++++++++ .github/workflows/release.yml | 119 +++++++++ .gitignore | 4 + Makefile | 70 ++++++ README.md | 28 +++ docs/development/releasing.md | 101 ++++++++ git-parallel | 116 +++++---- hephaestus.yaml | 71 ++++++ renovate.json | 42 ++++ scripts/check-version.sh | 25 ++ scripts/homebrew-e2e.sh | 109 +++++++++ tests/run.sh | 449 ++++++++++++++++++++++++++++++++++ 12 files changed, 1260 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 docs/development/releasing.md create mode 100644 hephaestus.yaml create mode 100644 renovate.json create mode 100755 scripts/check-version.sh create mode 100755 scripts/homebrew-e2e.sh create mode 100755 tests/run.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..08c1feb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,168 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + # The release workflow runs this whole CI on the tagged commit first. + workflow_call: + +permissions: + contents: read + +concurrency: + # Called from release.yml, github.workflow is "Release": the group differs + # from the release workflow's own, so the call never cancels itself. + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + # No go.mod here: Go only runs pinned tools (actionlint, hephaestus). + go-version: stable + cache: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: lts/* + - name: ShellCheck + run: shellcheck --version && make lint + - name: actionlint + run: make actionlint + - name: Renovate configuration + run: make renovate-check + + test: + # Behavior tests (tests/run.sh) with every bash git-parallel supports: + # bash 5 on Linux and macOS, and the bash 3.2 that ships with macOS. + strategy: + fail-fast: false + matrix: + include: + - {os: ubuntu-latest, bash: bash, major: "5"} + - {os: ubuntu-24.04-arm, bash: bash, major: "5"} + - {os: macos-latest, bash: /bin/bash, major: "3"} + - {os: macos-latest, bash: homebrew, major: "5"} + name: test (${{ matrix.os }}, bash ${{ matrix.major }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Select bash + id: bash + env: + WANT: ${{ matrix.bash }} + MAJOR: ${{ matrix.major }} + run: | + set -euo pipefail + if [[ "$WANT" == homebrew ]]; then + WANT="$(brew --prefix)/bin/bash" + if [[ ! -x "$WANT" ]]; then + HOMEBREW_NO_AUTO_UPDATE=1 brew install bash + fi + fi + "$WANT" --version | head -1 + # shellcheck disable=SC2016 # expanded by the bash under test + got="$("$WANT" -c 'echo "${BASH_VERSINFO[0]}"')" + [[ "$got" == "$MAJOR" ]] || { echo "::error::$WANT is bash $got, expected $MAJOR"; exit 1; } + echo "path=$WANT" >> "$GITHUB_OUTPUT" + - name: Tests + env: + TEST_BASH: ${{ steps.bash.outputs.path }} + run: make test TEST_BASH="$TEST_BASH" + - name: Smoke (--version / --help) + env: + TEST_BASH: ${{ steps.bash.outputs.path }} + run: | + set -uo pipefail + # --version and --help exit 1 (historical behavior). + out="$("$TEST_BASH" ./git-parallel --version)"; rc=$? + echo "$out" + [[ $rc == 1 && "$out" =~ ^git-parallel\ v[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1 + help="$("$TEST_BASH" ./git-parallel --help 2>&1)"; rc=$? + [[ $rc == 1 && "$help" == USAGE:* ]] || exit 1 + + hephaestus: + # The release packaging, as the release workflow runs it, without a tag + # or any token: configuration check, then archive and checksum. + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check configuration + uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 + with: + command: check + github-token: "" + - name: Snapshot (archive, checksum) + id: snapshot + uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 + with: + command: run + stages: archive,checksum + snapshot: "true" + fresh: "true" + github-token: "" + - name: Verify the archive + env: + ARTIFACTS: ${{ steps.snapshot.outputs.artifacts }} + run: | + set -euo pipefail + echo "$ARTIFACTS" | jq . + echo "$ARTIFACTS" | jq -e 'length == 2' + archive=dist/git-parallel_0.0.0-SNAPSHOT_noarch.tar.gz + tar -tzvf "$archive" + [[ "$(tar -tzf "$archive" | sort | tr '\n' ' ')" == "README.md git-parallel git-parallel.1 " ]] + tar -tzvf "$archive" | grep -Eq '^-rwxr-xr-x .* git-parallel$' + (cd dist && sha256sum -c git-parallel_0.0.0-SNAPSHOT_checksums.txt) + # The archived script is the checked-out one. + tar -xzOf "$archive" git-parallel | cmp - git-parallel + + homebrew-e2e: + # Archive → formula (file:// url) → brew style, audit --strict, + # install, test, git parallel, uninstall with the real Homebrew. + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + cache: false + - name: Put Homebrew on PATH (Linux) + if: runner.os == 'Linux' + run: echo "/home/linuxbrew/.linuxbrew/bin" >> "$GITHUB_PATH" + - name: Homebrew end-to-end + run: make homebrew-e2e + + ci-gate: + # One check that covers every job; the release workflow needs it too. + if: always() + needs: [lint, test, hephaestus, homebrew-e2e] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Every job succeeded + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + echo "$RESULTS" + for r in $RESULTS; do [[ "$r" == success ]] || exit 1; done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0d00245 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,119 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + ci: + # The full CI, at the tagged commit, is the release gate. + uses: ./.github/workflows/ci.yml + permissions: + contents: read + + build: + # Packages the release with a read-only token, no environment and no + # secrets. git-parallel is a script: there is no build stage, only the + # noarch archive and its checksum. Only the verified dist/ moves on. + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: The script reports the tag being released + # The version is written in git-parallel (--version); refuse a tag + # that does not match it before anything is packaged. + env: + TAG: ${{ github.ref_name }} + run: scripts/check-version.sh "$TAG" + - name: Archive and checksum + uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 + with: + command: run + stages: archive,checksum + fresh: "true" + github-token: "" + - name: Verify dist + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + version="${TAG#v}" + archive="dist/git-parallel_${version}_noarch.tar.gz" + tar -tzvf "$archive" + [[ "$(tar -tzf "$archive" | sort | tr '\n' ' ')" == "README.md git-parallel git-parallel.1 " ]] + (cd dist && sha256sum -c "git-parallel_${version}_checksums.txt") + tar -xzOf "$archive" git-parallel | cmp - git-parallel + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: dist + path: dist/ + if-no-files-found: error + retention-days: 7 + + release: + # Publishes the verified dist/ from the build job; nothing is built here. + # The environment holds HOMEBREW_TAP_GITHUB_TOKEN; its deployment policy + # must allow tags v* (see docs/development/releasing.md). + needs: [ci, build] + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: release + permissions: + contents: write # create the release and upload assets + id-token: write # sign provenance attestations + attestations: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: dist + path: dist + - name: The tap token is available + # Without a token the dry run below cannot read the tap and would + # pass. Only whether it is set reaches this step, never its value. + env: + HAS_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN != '' }} + run: | + [[ "$HAS_TAP_TOKEN" == true ]] || { echo "::error::HOMEBREW_TAP_GITHUB_TOKEN is not set in the release environment"; exit 1; } + - name: Rehearse the formula and tap pull request (read-only) + # Checks the tap token, the tap layout, a newer formula on the tap + # and the pull request branch before the release becomes public, + # since a published release cannot be taken back. + uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 + with: + command: run + stages: formula,tap + dry-run: "true" + github-token: "" + tap-token: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: | + dist/git-parallel_*_noarch.tar.gz + dist/git-parallel_*_checksums.txt + - name: Publish the GitHub release + uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 + with: + command: release + - name: Generate the formula and open a tap pull request + # Prereleases (vX.Y.Z-rc.N) are skipped by brews.skip_upload: auto. + uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 + with: + command: run + stages: formula,tap + github-token: "" + tap-token: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index e272b58..a4960cc 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,7 @@ Network Trash Folder Temporary Items .apdisk + +### Hephaestus +# Release artifacts (make snapshot, the release workflow) +/dist/ diff --git a/Makefile b/Makefile index 976dc5b..e29f915 100644 --- a/Makefile +++ b/Makefile @@ -13,3 +13,73 @@ uninstall: rm -f $(PREFIX)/bin/git-parallel rm -f $(PREFIX)/share/man/man1/git-parallel.1 + +# --------------------------------------------------------------------------- +# Development and release checks. `make help` lists them. The targets above +# (all, install, uninstall) are unchanged. + +# Bash used to run the tests; TEST_BASH=/bin/bash checks macOS's bash 3.2. +TEST_BASH ?= bash +SHELLCHECK ?= shellcheck +SHELL_SOURCES := git-parallel tests/run.sh scripts/check-version.sh scripts/homebrew-e2e.sh + +# renovate: datasource=go depName=github.com/magicdrive/hephaestus +HEPHAESTUS_VERSION ?= v1.1.0 +HEPHAESTUS ?= go run github.com/magicdrive/hephaestus@$(HEPHAESTUS_VERSION) +# renovate: datasource=go depName=github.com/rhysd/actionlint +ACTIONLINT_VERSION ?= v1.7.12 +# renovate: datasource=npm depName=renovate +RENOVATE_VERSION ?= 44.149.0 + +.PHONY: all install uninstall help lint test check actionlint renovate-check \ + hephaestus-check snapshot version-check preflight release-check homebrew-e2e + +help: + @echo "make install|uninstall install to / remove from PREFIX ($(PREFIX))" + @echo "make lint shellcheck the scripts" + @echo "make test [TEST_BASH=...] run tests/run.sh with the given bash" + @echo "make check lint + test" + @echo "make actionlint lint the GitHub Actions workflows" + @echo "make renovate-check validate renovate.json (needs npx)" + @echo "make hephaestus-check validate hephaestus.yaml" + @echo "make snapshot build the release archive and checksum into dist/ (no tag)" + @echo "make version-check tag=vX.Y.Z the script's --version matches the tag" + @echo "make preflight tag=vX.Y.Z version-check + hephaestus preflight --strict before tagging" + @echo "make release-check tag=vX.Y.Z check + hephaestus-check + snapshot + preflight" + @echo "make homebrew-e2e package, then brew style/audit/install/test/uninstall (CI)" + +lint: + $(SHELLCHECK) $(SHELL_SOURCES) + +test: + TEST_BASH=$(TEST_BASH) tests/run.sh + +check: lint test + +actionlint: + go run github.com/rhysd/actionlint/cmd/actionlint@$(ACTIONLINT_VERSION) + +renovate-check: + npx --yes --package renovate@$(RENOVATE_VERSION) -- renovate-config-validator --strict renovate.json + +hephaestus-check: + $(HEPHAESTUS) check + +# git-parallel is a script: there is no build stage, only the noarch archive. +snapshot: + $(HEPHAESTUS) run --snapshot --stages archive,checksum --fresh + +version-check: + @test -n "$(tag)" || { echo "usage: make $@ tag=vX.Y.Z" >&2; exit 2; } + scripts/check-version.sh "$(tag)" + +preflight: version-check + $(HEPHAESTUS) preflight --tag "$(tag)" --strict + +release-check: check hephaestus-check snapshot preflight + +# Installs into the machine's Homebrew prefix (removed again on exit): meant +# for CI runners and containers. +homebrew-e2e: + GOBIN="$(CURDIR)/dist/.bin" go install github.com/magicdrive/hephaestus@$(HEPHAESTUS_VERSION) + HEPHAESTUS="$(CURDIR)/dist/.bin/hephaestus" scripts/homebrew-e2e.sh diff --git a/README.md b/README.md index 4511e49..f65dc9f 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,34 @@ ex) git parallel --maxdepth 3 --jobs 4 -- ::: ls -lha ``` +Install +------- + +``` +make install # copies git-parallel and git-parallel.1 under PREFIX (default: ~/local) +make uninstall +``` + +With Homebrew (from the first release published with Hephaestus on): + +``` +brew install magicdrive/tap/git-parallel +``` + +`git-parallel` runs with bash 3.2 (macOS) or later. Once it is on your `PATH`, +git runs it as `git parallel`. + +Development +----------- + +``` +make check # shellcheck + tests +make test TEST_BASH=/bin/bash # tests with a specific bash +``` + +Releases are made with [Hephaestus](https://github.com/magicdrive/hephaestus): +see [docs/development/releasing.md](docs/development/releasing.md). + Author diff --git a/docs/development/releasing.md b/docs/development/releasing.md new file mode 100644 index 0000000..c337bf9 --- /dev/null +++ b/docs/development/releasing.md @@ -0,0 +1,101 @@ +# Releasing git-parallel + +Releases are built and published by [Hephaestus](https://github.com/magicdrive/hephaestus) +(`hephaestus.yaml`) from `.github/workflows/release.yml` when a `v*` tag is +pushed. git-parallel is a Bash script, so a release is one +architecture-independent archive, `git-parallel__noarch.tar.gz` +(`git-parallel`, `git-parallel.1`, `README.md`), its checksum file +`git-parallel__checksums.txt`, build provenance attestations, and a +pull request to [magicdrive/homebrew-tap](https://github.com/magicdrive/homebrew-tap) +for the `git-parallel` formula. + +## One-time setup (repository owner) + +- **Environment `release`** holds the secret `HOMEBREW_TAP_GITHUB_TOKEN`: a + fine-grained token (or GitHub App token) with `contents: write` and + `pull-requests: write` on `magicdrive/homebrew-tap` only. +- **Its deployment policy must allow tags `v*`.** The release job runs for a + tag push; a policy that lists only *branches* `v*` rejects it before any + step runs. Add a tag policy `v*`, and remove the branch policy `v*` + (`hephaestus preflight` reports a branch policy as a failure: a branch + named like a version could read the release secret). +- Recommended: a ruleset that protects `main`, and a tag ruleset on `v*` + that forbids updating and deleting tags (see the Hephaestus + `docs/preflight.md#recommended-settings`). + +## Steps + +1. **Set the version.** The version is written in the script + (`git::parallel::version`, printed by `git parallel --version`); nothing + rewrites it at release time. Change it to the new version in a pull + request and merge it after CI passes. +2. **Check before tagging**, on an up-to-date `main` with a clean tree: + + ```sh + make preflight tag=vX.Y.Z + ``` + + This checks that `git-parallel --version` prints `git-parallel vX.Y.Z`, + then runs `hephaestus preflight --tag vX.Y.Z --strict` (read-only: + repository state, configuration, workflow, environment, secret names, + tap). It needs a GitHub token that can read the repository (for example + `GITHUB_TOKEN="$(gh auth token)"`). `make release-check tag=vX.Y.Z` also + runs the tests and a local snapshot. +3. **Push the tag:** + + ```sh + git tag vX.Y.Z + git push origin vX.Y.Z + ``` + +4. **The workflow** runs the full CI on the tagged commit, then + - `build` (read-only, no secrets): refuses a tag that differs from the + script's version, archives, checksums and verifies `dist/`; + - `release` (environment `release`): rehearses the tap pull request + (dry run), attests the archive and checksum file, publishes the GitHub + release (notes generated by GitHub), then opens the tap pull request. + Prereleases (`vX.Y.Z-rc.N`) are released but do not update the tap. +5. **Review and merge the tap pull request** ("Brew formula update for + git-parallel version vX.Y.Z"). The first one adds `git-parallel.rb`; + after it is merged: + + ```sh + brew install magicdrive/tap/git-parallel + ``` + +## Local checks + +```sh +make check # shellcheck + tests (bash on PATH) +make test TEST_BASH=/bin/bash # tests with macOS's bash 3.2 +make hephaestus-check # validate hephaestus.yaml +make snapshot # dist/git-parallel_0.0.0-SNAPSHOT_noarch.tar.gz + checksum +``` + +`make homebrew-e2e` packages the tree, installs the generated formula from a +`file://` url into a throw-away tap and runs `brew style`, `brew audit +--strict`, `brew install`, `brew test` and `brew uninstall`. It writes into +the machine's Homebrew prefix, so CI runs it (`homebrew-e2e` job); locally, +prefer a container such as `homebrew/brew`. + +## When something fails + +Nothing is ever fixed by moving or deleting a tag. Hephaestus resumes a +release by running the same thing again (Hephaestus `docs/recovery.md`): + +- **Before the release is published** (CI, `build`, the tap rehearsal or the + attestation failed): nothing is public. Fix the cause; if the fix needs a + code change, release a new version. Otherwise re-run the failed jobs. +- **Environment rejected the deployment**: fix the deployment policy (see + above) and re-run the failed jobs. +- **Publishing stopped halfway**: re-run the failed `release` job. Identical + assets are skipped, a draft is reused; an asset that differs is never + overwritten (`hephaestus status --tag vX.Y.Z` shows the state). +- **The tap pull request failed after publishing**: re-run the failed + `release` job (within the 7 days the `dist` artifact is kept): the release + step finds everything already published and the tap step resumes; an + existing tap branch or pull request with the same content is reused. + +To undo the migration to Hephaestus itself, revert the pull request that +introduced `hephaestus.yaml` and the workflows; no earlier release depends +on them. diff --git a/git-parallel b/git-parallel index c7f859f..463c4b0 100755 --- a/git-parallel +++ b/git-parallel @@ -52,6 +52,7 @@ export GIT_PARALLEL_DEAULT_MAXDEPTH="${GIT_PARALLEL_DEAULT_MAXDEPTH:-""}" export GIT_PARALLEL_DEAULT_START_DIR="${GIT_PARALLEL_DEAULT_START_DIR:-$(cd ./ && pwd)}" trap git::parallel::exit HUP INT QUIT TERM +trap git::parallel::cleanup EXIT git::parallel::help() { cat << HELP >&2 @@ -82,24 +83,33 @@ HELP git::parallel::reccursive_ps_killer() { local target_ps=$1 - for x in $(ps --ppid ${target_ps} --no-heading | awk '{ print $1 }'); do + local x + # pgrep -P works on both Linux (procps) and macOS; `ps --ppid` is + # GNU-only. + for x in $(pgrep -P "${target_ps}" 2>/dev/null); do git::parallel::reccursive_ps_killer "${x}" done - kill -9 ${target_ps} + kill -9 "${target_ps}" 2>/dev/null } git::parallel::find_targets() { - MAXDEPTH_OPT="" + local maxdepth_opt=() if [[ -n ${GIT_PARALLEL_DEAULT_MAXDEPTH} ]];then - MAXDEPTH_OPT="-maxdepth "${GIT_PARALLEL_DEAULT_MAXDEPTH}"" + maxdepth_opt=(-maxdepth "${GIT_PARALLEL_DEAULT_MAXDEPTH}") fi - TARGET_REPOSITORIES=($(find -L "${GIT_PARALLEL_DEAULT_START_DIR}" \ - ${MAXDEPTH_OPT} -name '.git' -type d | \ - perl -p -e "s{^\./}{}" | \ - perl -p -e "s{/\.git$}{}" | \ - grep -v "^$")) + # One repository per line, so that paths containing spaces stay intact. + TARGET_REPOSITORIES=() + local line + while IFS= read -r line; do + line="${line#./}" + line="${line%/.git}" + if [[ -n ${line} ]];then + TARGET_REPOSITORIES+=("${line}") + fi + done < <(find -L "${GIT_PARALLEL_DEAULT_START_DIR}" \ + ${maxdepth_opt[@]+"${maxdepth_opt[@]}"} -name '.git' -type d) } git::parallel::command() { @@ -140,30 +150,37 @@ git::parallel::version() { git::parallel::freeze() { local repo="$1" - if [ -d ${repo}/.git ];then - touch ${repo}/.git-parallel.freeze - echo ${repo} now frozen. + if [ -d "${repo}/.git" ];then + touch "${repo}/.git-parallel.freeze" + echo "${repo} now frozen." else - echo ${repo} is not git-repository. >&2 + echo "${repo} is not git-repository." >&2 fi } +git::parallel::cleanup() { + if [[ -n ${GIT_PARALLEL_TMPDIR} && -d ${GIT_PARALLEL_TMPDIR} ]];then + rm -rf "${GIT_PARALLEL_TMPDIR}" + fi +} git::parallel::exit() { + git::parallel::cleanup git::parallel::reccursive_ps_killer $$ } git::parallel::show_list() { git::parallel::find_targets - for x in ${TARGET_REPOSITORIES[@]};do + local x + for x in "${TARGET_REPOSITORIES[@]}";do echo "${x}" done } git::parallel::execute() { local repository=$1 - local repository_name=$(basename ${repository}) - local git_command="${GIT_PARALLEL_EXEC_CMD}" + local repository_name + repository_name=$(basename "${repository}") [ -d "${repository}/.git" ] || return; @@ -181,20 +198,24 @@ git::parallel::execute() { print_repo_name="${repository_name}" fi - cd ${repository} + cd "${repository}" || return echo -e "${print_cmd} ::: ${print_repo_name}" if [ -f "${repository}/.git-parallel.freeze" ];then echo skip. echo .git-parallel.freeze found. - echo ${repository} is frozen. + echo "${repository} is frozen." echo return fi + # GIT_COLOR_CONFIG and CMD are split into words on purpose: the command + # is given as one string (see git::parallel::command). if [[ $CMD_MODE = git ]];then - git -C ${repository} ${GIT_COLOR_CONFIG} ${CMD} 2>&1 + # shellcheck disable=SC2086 + git -C "${repository}" ${GIT_COLOR_CONFIG} ${CMD} 2>&1 elif [[ $CMD_MODE = bare ]];then + # shellcheck disable=SC2086 ${CMD} 2>&1 fi echo @@ -204,51 +225,62 @@ git::parallel::job_pack() { local repo=${TARGET_REPOSITORIES[0]} TARGET_REPOSITORIES=("${TARGET_REPOSITORIES[@]:1}") if [[ -d ${repo} ]];then - exec {FD}< <(git::parallel::execute "${repo}") - FD_LIST=(${FD_LIST[@]} ${FD}) + # Each job writes to its own file, printed in order once the batch + # has finished. (Named file descriptors, `exec {FD}<`, need bash 4.1 + # and fail on the bash 3.2 that ships with macOS.) + local out="${GIT_PARALLEL_TMPDIR}/${#OUT_LIST[@]}" + git::parallel::execute "${repo}" > "${out}" & + OUT_LIST+=("${out}") fi } git::parallel::main() { - FD_LIST=() + local x + + # `seq 0` prints nothing with GNU coreutils, so zero jobs used to loop + # forever. + if [[ ! ${GIT_PARALLEL_DEAULT_JOBS} =~ ^[[:digit:]]+$ ]] || (( 10#${GIT_PARALLEL_DEAULT_JOBS} < 1 ));then + echo "Invalid job count: ${GIT_PARALLEL_DEAULT_JOBS} (must be at least 1)" >&2 + exit 1 + fi git::parallel::command git::parallel::color git::parallel::find_targets + GIT_PARALLEL_TMPDIR=$(mktemp -d "${TMPDIR:-/tmp}/git-parallel.XXXXXX") || exit 1 + if [[ ${GIT_PARALLEL_DEAULT_SILENT_MODE} = FALSE ]];then echo "start parallel: ${GIT_PARALLEL_EXEC_CMD} "; echo; fi while true; do + OUT_LIST=() for x in $(seq "${GIT_PARALLEL_DEAULT_JOBS}"); do git::parallel::job_pack done wait - if [[ ${GIT_PARALLEL_DEAULT_SILENT_MODE} = TRUE ]];then - for x in ${FD_LIST[@]}; do - : <&${x} - done - else - for x in ${FD_LIST[@]}; do - cat <&${x} - done - fi + for x in "${OUT_LIST[@]}"; do + if [[ ${GIT_PARALLEL_DEAULT_SILENT_MODE} != TRUE ]];then + cat "${x}" + fi + rm -f "${x}" + done - if [[ -z ${TARGET_REPOSITORIES} ]];then + if [[ ${#TARGET_REPOSITORIES[@]} -eq 0 ]];then break fi done } -if [[ -z $@ ]];then +if [[ -z $* ]];then git::parallel::help && exit 1 fi -if [[ ! $1 =~ ^-+.*$ ]] || [[ $@ =~ ^-[[:blank:]]+.*$ ]];then - echo "Unknown option $@" >&2 +if [[ ! $1 =~ ^-+.*$ ]] || [[ $* =~ ^-[[:blank:]]+.*$ ]];then + echo "Unknown option $*" >&2 exit 1 fi @@ -267,11 +299,11 @@ while getopts "$optspec" optchar; do git::parallel::show_list && exit 1 ;; freeze) - val="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 )) + val="${!OPTIND}"; OPTIND=$(( OPTIND + 1 )) git::parallel::freeze "${val}" && exit 1 ;; jobs) - val="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 )) + val="${!OPTIND}"; OPTIND=$(( OPTIND + 1 )) if [[ ${val} =~ ^[[:digit:]]+$ ]]; then GIT_PARALLEL_DEAULT_JOBS="${val}" else @@ -280,7 +312,7 @@ while getopts "$optspec" optchar; do fi ;; color) - val="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 )) + val="${!OPTIND}"; OPTIND=$(( OPTIND + 1 )) if [[ ${val} =~ ^(auto|always|never)$ ]]; then GIT_PARALLEL_DEAULT_COLOR_MODE="${val}" else @@ -289,7 +321,7 @@ while getopts "$optspec" optchar; do fi ;; maxdepth) - val="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 )) + val="${!OPTIND}"; OPTIND=$(( OPTIND + 1 )) if [[ ${val} =~ ^[[:digit:]]+$ ]]; then GIT_PARALLEL_DEAULT_MAXDEPTH="${val}" else @@ -297,8 +329,8 @@ while getopts "$optspec" optchar; do exit 1 fi ;; - target) - val="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 )) + target|targets) + val="${!OPTIND}"; OPTIND=$(( OPTIND + 1 )) GIT_PARALLEL_DEAULT_START_DIR="${val}" ;; silent) @@ -368,7 +400,7 @@ done shift $((OPTIND - 1)) -GIT_PARALLEL_EXEC_CMD=$@ +GIT_PARALLEL_EXEC_CMD=$* if [[ -z ${GIT_PARALLEL_EXEC_CMD} ]];then git::parallel::help && exit 1 diff --git a/hephaestus.yaml b/hephaestus.yaml new file mode 100644 index 0000000..e2d888e --- /dev/null +++ b/hephaestus.yaml @@ -0,0 +1,71 @@ +# Release configuration for Hephaestus (https://github.com/magicdrive/hephaestus). +# +# git-parallel is one Bash script and its man page, so there is no build +# stage: a single architecture-independent ("noarch") archive serves every +# platform, and the Homebrew formula installs it on macOS and Linux, Intel +# and ARM, from one url. +# +# make snapshot # archive + checksum locally, no tag needed +# make preflight tag=vX.Y.Z # release safety checks before pushing a tag +# +# The release workflow (.github/workflows/release.yml) runs on a v* tag +# after CI passes. See docs/development/releasing.md. +version: 1 + +project: + name: git-parallel + description: Run commands in parallel across multiple git repositories + homepage: https://github.com/magicdrive/git-parallel + license: MIT + +archives: + - id: script + name: "{{ .Project }}_{{ .Version }}_noarch" + targets: [{os: all, arch: all}] + files: + - {src: git-parallel, dst: git-parallel, mode: "0755"} + - {src: git-parallel.1, dst: git-parallel.1, mode: "0644"} + - {src: README.md, dst: README.md, mode: "0644"} + +checksum: + name: "{{ .Project }}_{{ .Version }}_checksums.txt" + +release: + github: + owner: magicdrive + name: git-parallel + # The repository has no changelog; GitHub writes the notes when the + # release is created. + generate_notes: true + +brews: + - name: git-parallel + ids: [script] + commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" + # magicdrive/homebrew-tap keeps its formulae at the repository root; + # creating Formula/ there would hide them from Homebrew. + # Prereleases (v1.2.3-rc.1) never update the tap. + skip_upload: auto + # No depends_on: the script runs on the bash 3.2 that ships with macOS + # and on any later bash (CI checks both), and as a git subcommand it is + # only useful where git is already installed. + install: | + bin.install "git-parallel" + man1.install "git-parallel.1" + # --version, --help and --list exit 1 (historical behavior). + test: | + assert_match "git-parallel v#{version}", shell_output("#{bin}/git-parallel --version", 1) + system "git", "init", "--quiet", "repo" + assert_match %r{/repo$}, shell_output("#{bin}/git-parallel --list", 1) + assert_match "result git status ::: repo", shell_output("#{bin}/git-parallel -- status") + repository: + owner: magicdrive + name: homebrew-tap + pull_request: + enabled: true + +# Release safety checks before a tag is pushed: make preflight tag=vX.Y.Z +# (hephaestus preflight, see the Hephaestus docs/preflight.md). +preflight: + workflow: release.yml + environment: release diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..ef64953 --- /dev/null +++ b/renovate.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":dependencyDashboard", + ":semanticCommits", + "group:allNonMajor" + ], + "timezone": "Asia/Tokyo", + "schedule": ["before 10am on monday"], + "labels": ["dependencies"], + "commitMessagePrefix": "chore(deps):", + "prConcurrentLimit": 5, + "prHourlyLimit": 2, + "rebaseWhen": "behind-base-branch", + "minimumReleaseAge": "3 days", + "packageRules": [ + { + "description": "GitHub Actions stay pinned to commit SHAs (magicdrive/hephaestus included)", + "matchManagers": ["github-actions"], + "groupName": "GitHub Actions", + "pinDigests": true, + "automerge": false + }, + { + "description": "Keep the Hephaestus CLI (Makefile) and the Hephaestus action (workflows) on the same release", + "matchPackageNames": ["github.com/magicdrive/hephaestus", "magicdrive/hephaestus"], + "groupName": "Hephaestus", + "automerge": false + } + ], + "customManagers": [ + { + "description": "Tool versions pinned in the Makefile: a '# renovate: datasource=... depName=...' comment above 'NAME ?= version'", + "customType": "regex", + "managerFilePatterns": ["/^Makefile$/"], + "matchStrings": [ + "# renovate: datasource=(?\\S+) depName=(?\\S+)\\n[A-Z_]+ \\?= (?\\S+)" + ] + } + ] +} diff --git a/scripts/check-version.sh b/scripts/check-version.sh new file mode 100755 index 0000000..b647e12 --- /dev/null +++ b/scripts/check-version.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Usage: scripts/check-version.sh vX.Y.Z +# +# The version is written into git-parallel (git::parallel::version, printed +# by --version / -V) and is not generated at release time. This check fails +# unless the script reports exactly the tag being released, so a release +# cannot ship a script that prints another version. The release workflow +# runs it before anything is packaged; make preflight runs it before tagging. +set -euo pipefail + +tag="${1:?usage: $0 vX.Y.Z}" +if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "error: ${tag} is not a vMAJOR.MINOR.PATCH[-PRERELEASE] tag" >&2 + exit 1 +fi + +script="$(cd "$(dirname "$0")/.." && pwd)/git-parallel" +# --version exits 1 (historical behavior); only the output matters here. +actual="$(bash "${script}" --version 2>&1 || true)" +if [[ "${actual}" != "git-parallel ${tag}" ]]; then + echo "error: git-parallel --version prints \"${actual}\", expected \"git-parallel ${tag}\"." >&2 + echo "Update git::parallel::version in git-parallel before tagging ${tag}." >&2 + exit 1 +fi +echo "git-parallel --version matches ${tag}" diff --git a/scripts/homebrew-e2e.sh b/scripts/homebrew-e2e.sh new file mode 100755 index 0000000..324075b --- /dev/null +++ b/scripts/homebrew-e2e.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Homebrew end-to-end test of the release packaging. +# +# Packages the working tree with Hephaestus exactly as the release workflow +# does (archive, checksum, formula) for the version the script reports, +# points the generated formula at the local archive (file:// url, same file +# name and sha256), then runs the real Homebrew in a throw-away tap: +# brew style, brew audit --strict, brew install, brew test, `git parallel` +# and the man page from the installed keg, and brew uninstall. +# +# It installs into the machine's Homebrew prefix and removes everything it +# created on exit. Intended for disposable CI runners and containers. +# +# HEPHAESTUS=/path/to/hephaestus scripts/homebrew-e2e.sh +set -euo pipefail +export LC_ALL=C + +repo="$(cd "$(dirname "$0")/.." && pwd)" +heph="${HEPHAESTUS:?set HEPHAESTUS to a hephaestus executable}" +formula="git-parallel" +tap="git-parallel-e2e/t${RANDOM}${RANDOM}" + +export HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_ANALYTICS=1 HOMEBREW_NO_ENV_HINTS=1 \ + HOMEBREW_NO_INSTALL_CLEANUP=1 HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 \ + HOMEBREW_DEVELOPER=1 HOMEBREW_NO_ASK=1 + +cleanup() { + set +e + brew uninstall --force "${tap}/${formula}" >/dev/null 2>&1 + brew untap --force "${tap}" >/dev/null 2>&1 +} +trap cleanup EXIT + +step() { printf '\n==> %s\n' "$*"; } + +# The version is embedded in the script (git-parallel --version); the +# release workflow refuses a tag that does not match it. +version="$(bash "${repo}/git-parallel" --version || true)" +version="${version#git-parallel v}" +[[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]] || { echo "cannot read the version from git-parallel" >&2; exit 1; } +tag="v${version}" + +step "Hephaestus: archive, checksum and formula for ${tag} (nothing is published)" +cd "${repo}" +env -u GITHUB_TOKEN -u GH_TOKEN -u HOMEBREW_TAP_GITHUB_TOKEN \ + "${heph}" run --stages archive,checksum,formula --tag "${tag}" --fresh +archive="dist/${formula}_${version}_noarch.tar.gz" +generated="dist/homebrew/${formula}.rb" + +step "Archive layout" +listing="$(tar -tzvf "${archive}")" +echo "${listing}" +names="$(tar -tzf "${archive}" | sort | tr '\n' ' ')" +[[ "${names}" == "README.md git-parallel git-parallel.1 " ]] || { echo "unexpected archive contents: ${names}" >&2; exit 1; } +grep -Eq -- '^-rwxr-xr-x .* git-parallel$' <<<"${listing}" +grep -Eq -- '^-rw-r--r-- .* git-parallel\.1$' <<<"${listing}" +if command -v sha256sum >/dev/null; then + (cd dist && sha256sum -c "${formula}_${version}_checksums.txt") +else + (cd dist && shasum -a 256 -c "${formula}_${version}_checksums.txt") +fi + +step "Generated formula" +cat "${generated}" +release_url="https://github.com/magicdrive/git-parallel/releases/download/${tag}/${formula}_${version}_noarch.tar.gz" +grep -qxF " url \"${release_url}\"" "${generated}" +if grep -q 'on_macos\|on_linux' "${generated}"; then echo "noarch formula has per-OS blocks" >&2; exit 1; fi + +step "Throw-away tap ${tap} with the formula pointing at the local archive" +brew tap-new --no-git "${tap}" >/dev/null +tapdir="$(brew --repository "${tap}")" +sed "s| url \"${release_url}\"| url \"file://${repo}/${archive}\"|" "${generated}" > "${tapdir}/Formula/${formula}.rb" +grep -q "file://" "${tapdir}/Formula/${formula}.rb" + +step "brew style" +# Only the formula: `brew style ` also lints the tap's generated +# workflows with actionlint. +brew style --formula "${tap}/${formula}" + +step "brew audit --strict" +brew audit --strict --formula "${tap}/${formula}" + +step "brew install" +brew install --formula "${tap}/${formula}" + +step "brew test" +brew test "${tap}/${formula}" + +step "Installed command, git subcommand and man page" +prefix="$(brew --prefix)" +out="$("${prefix}/bin/git-parallel" --version || true)" +[[ "${out}" == "git-parallel ${tag}" ]] || { echo "unexpected --version: ${out}" >&2; exit 1; } +out="$(PATH="${prefix}/bin:${PATH}" git parallel -V || true)" +[[ "${out}" == "git-parallel ${tag}" ]] || { echo "unexpected git parallel -V: ${out}" >&2; exit 1; } +[[ -f "$(brew --prefix "${tap}/${formula}")/share/man/man1/git-parallel.1" ]] +work="$(mktemp -d)" +git -C "${work}" init -q one && git -C "${work}" init -q two +out="$(cd "${work}" && PATH="${prefix}/bin:${PATH}" git parallel --jobs 2 -- status --short)" +rm -rf "${work}" +if ! grep -q '::: one' <<<"${out}" || ! grep -q '::: two' <<<"${out}"; then + echo "unexpected git parallel output: ${out}" >&2; exit 1 +fi + +step "brew uninstall" +brew uninstall "${tap}/${formula}" +[[ ! -e "${prefix}/bin/git-parallel" ]] + +echo +echo "Homebrew E2E passed for ${tag} on $(uname -s)/$(uname -m)" diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..2f0d76e --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,449 @@ +#!/usr/bin/env bash +# Tests for git-parallel. +# +# Every test builds its own tree of throw-away git repositories under a +# temporary directory and runs git-parallel against it with the bash given in +# TEST_BASH (default: the first bash on PATH), so the suite can check bash 3.2 +# (macOS /bin/bash) and bash 5 alike: +# +# tests/run.sh # bash on PATH +# TEST_BASH=/bin/bash tests/run.sh # a specific bash +# +# The suite itself stays bash 3.2 compatible. Nothing outside the temporary +# directory is touched; git runs with HOME pointed into it and no system or +# global configuration. +set -u + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +GP="${repo_root}/git-parallel" +TEST_BASH="${TEST_BASH:-bash}" +TEST_BASH="$(command -v "${TEST_BASH}")" || { echo "bash not found: ${TEST_BASH:-bash}" >&2; exit 2; } + +# cd + pwd: the same spelling git-parallel prints (no "//" from a TMPDIR +# that ends in a slash). +work="$(cd "$(mktemp -d "${TMPDIR:-/tmp}/git-parallel-test.XXXXXX")" && pwd)" +trap 'rm -rf "${work}"' EXIT + +export HOME="${work}/home" GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null \ + GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.com \ + GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.com \ + LC_ALL=C +mkdir -p "${HOME}" +# Defaults come from the environment; the tests must not inherit a user's. +unset GIT_PARALLEL_DEAULT_COLOR_MODE GIT_PARALLEL_DEAULT_SILENT_MODE \ + GIT_PARALLEL_DEAULT_JOBS GIT_PARALLEL_DEAULT_MAXDEPTH GIT_PARALLEL_DEAULT_START_DIR + +passed=0 failed=0 current="" +out="" err="" rc=0 + +fail() { + echo " FAIL: $*" + echo " exit: ${rc}" + echo " stdout:"; printf '%s\n' "${out}" | sed 's/^/ | /' + echo " stderr:"; printf '%s\n' "${err}" | sed 's/^/ | /' + failed=$((failed + 1)) + return 1 +} + +# run DIR ARGS...: run git-parallel in DIR; sets out, err and rc. +run() { + local dir="$1"; shift + out="$(cd "${dir}" && "${TEST_BASH}" "${GP}" "$@" 2>"${work}/stderr")" + rc=$? + err="$(cat "${work}/stderr")" +} + +expect_rc() { [[ "${rc}" == "$1" ]] || fail "${current}: exit ${rc}, want $1"; } +expect_out() { [[ "${out}" == *"$1"* ]] || fail "${current}: stdout lacks: $1"; } +expect_no_out() { [[ "${out}" != *"$1"* ]] || fail "${current}: stdout has: $1"; } +expect_err() { [[ "${err}" == *"$1"* ]] || fail "${current}: stderr lacks: $1"; } +expect_eq() { [[ "$1" == "$2" ]] || fail "${current}: got [$1], want [$2]"; } + +# new_tree NAME REPO...: a fresh directory with one git repository per +# (relative) path; each repository has one commit. +new_tree() { + local root="${work}/$1" r; shift + rm -rf "${root}"; mkdir -p "${root}" + for r in "$@"; do + mkdir -p "${root}/${r}" + git -C "${root}/${r}" init -q + echo "${r}" > "${root}/${r}/name.txt" + git -C "${root}/${r}" add name.txt + git -C "${root}/${r}" commit -q -m "init ${r}" + done + tree="${root}" +} + +t() { + current="$1" + echo "- $1" + local before="${failed}" + "$2" + [[ "${failed}" == "${before}" ]] && passed=$((passed + 1)) + return 0 +} + +test_version() { + local version + version="$(sed -n 's/^ *echo "git-parallel \(v[0-9][0-9.]*\)"$/\1/p' "${GP}")" + [[ -n "${version}" ]] || { fail "version string not found in git-parallel"; return; } + run "${work}" --version + # --version, --help and --list exit 1 (historical behavior, kept). + expect_rc 1 + expect_eq "${out}" "git-parallel ${version}" + run "${work}" -V + expect_rc 1 + expect_eq "${out}" "git-parallel ${version}" +} + +test_help() { + run "${work}" --help + expect_rc 1 + expect_err "USAGE:" + expect_err "--maxdepth" + run "${work}" -h + expect_rc 1 + expect_err "USAGE:" + run "${work}" + expect_rc 1 + expect_err "USAGE:" + run "${work}" --jobs 2 -- + expect_rc 1 + expect_err "USAGE:" +} + +test_unknown_options() { + run "${work}" --no-such-option + expect_rc 1 + expect_err "Unknown option --no-such-option" + run "${work}" status + expect_rc 1 + expect_err "Unknown option status" + run "${work}" --jobs abc -- status + expect_rc 1 + expect_err "Unknown option --jobs abc" + run "${work}" -j x -- status + expect_rc 1 + expect_err "Unknown option -j x" + run "${work}" --maxdepth deep -- status + expect_rc 1 + expect_err "Unknown option --maxdepth deep" + run "${work}" --color sometimes -- status + expect_rc 1 + expect_err "Unknown option --color sometimes" +} + +test_list() { + new_tree list alpha beta "nested/gamma" delta + mkdir -p "${tree}/plain-dir" "${tree}/not a repo" + run "${tree}" --list + expect_rc 1 + local got want + got="$(printf '%s\n' "${out}" | sort)" + want="$(printf '%s\n' "${tree}/alpha" "${tree}/beta" "${tree}/delta" "${tree}/nested/gamma" | sort)" + expect_eq "${got}" "${want}" + run "${tree}" -l + expect_rc 1 + expect_eq "$(printf '%s\n' "${out}" | sort)" "${want}" +} + +test_maxdepth() { + new_tree depth top "one/two" "one/two/three/four" + # .git of "top" is at depth 2, of "one/two" at depth 3. Options are + # applied in order, so --maxdepth must come before --list. + run "${tree}" --maxdepth 2 --list + expect_eq "${out}" "${tree}/top" + run "${tree}" -m 3 -l + expect_eq "$(printf '%s\n' "${out}" | sort)" "$(printf '%s\n' "${tree}/one/two" "${tree}/top" | sort)" + run "${tree}" --maxdepth 2 -- ::: pwd + expect_rc 0 + expect_out "${tree}/top" + expect_no_out "${tree}/one/two" +} + +test_targets() { + new_tree targets r1 r2 + # -d, --target and --targets (as the help spells it) select the start + # directory instead of the current one. + run "${work}" -d "${tree}" --list + expect_eq "$(printf '%s\n' "${out}" | sort)" "$(printf '%s\n' "${tree}/r1" "${tree}/r2")" + run "${work}" --target "${tree}" --list + expect_eq "$(printf '%s\n' "${out}" | sort)" "$(printf '%s\n' "${tree}/r1" "${tree}/r2")" + run "${work}" --targets "${tree}" --list + expect_eq "$(printf '%s\n' "${out}" | sort)" "$(printf '%s\n' "${tree}/r1" "${tree}/r2")" + run "${work}" --targets "${tree}" -- rev-parse --abbrev-ref HEAD + expect_rc 0 + expect_out "::: r1" + expect_out "::: r2" +} + +test_git_command() { + new_tree gitcmd r1 r2 r3 + run "${tree}" -- log --format=%s + expect_rc 0 + expect_out "start parallel: log --format=%s" + local r + for r in r1 r2 r3; do + expect_out "result git log --format=%s ::: ${r}" + expect_out "init ${r}" + done + # A leading "git" is accepted and not doubled. + run "${tree}" -- git rev-parse --is-inside-work-tree + expect_rc 0 + expect_out "result rev-parse --is-inside-work-tree ::: r1" + expect_out "true" + expect_no_out "git git" +} + +test_bare_command() { + new_tree bare r1 r2 + run "${tree}" -- ::: cat name.txt + expect_rc 0 + expect_out "result cat name.txt ::: r1" + expect_out "result cat name.txt ::: r2" + # Each command runs inside its repository. + run "${tree}" -j 1 -- ::: pwd + expect_out "${tree}/r1" + expect_out "${tree}/r2" +} + +test_output_order() { + # Output of each repository is printed as one block, in list order, + # whatever order the jobs finish in. + new_tree order r1 r2 r3 r4 + run "${tree}" --list + local expected="" r + while IFS= read -r r; do + expected="${expected}$(basename "${r}") " + done <<<"${out}" + run "${tree}" --jobs 4 -- ::: cat name.txt + local got + got="$(printf '%s\n' "${out}" | sed -n 's/^result cat name.txt ::: //p' | tr '\n' ' ')" + expect_eq "${got}" "${expected}" +} + +test_paths_with_spaces() { + new_tree "space root" "with space" "plain" "deep dir/inner repo" + run "${tree}" --list + expect_eq "$(printf '%s\n' "${out}" | sort)" \ + "$(printf '%s\n' "${tree}/deep dir/inner repo" "${tree}/plain" "${tree}/with space" | sort)" + run "${tree}" -- log --format=%s + expect_rc 0 + expect_out "::: with space" + expect_out "init with space" + expect_out "::: inner repo" + expect_out "init deep dir/inner repo" + run "${tree}" -- ::: pwd + expect_out "${tree}/with space" + run "${work}" --targets "${tree}" -- ::: cat name.txt + expect_out "deep dir/inner repo" +} + +test_argument_quoting() { + new_tree quoting r1 + # The command is joined into one string and split again on white space + # (historical behavior): quoting inside an argument is not preserved, and + # a command given as a single quoted argument works the same as separate + # words. + run "${tree}" -- "log --format=%s" + expect_rc 0 + expect_out "init r1" + run "${tree}" -- ::: printf '<%s>' "a b" c + expect_out "" + run "${tree}" -- "::: printf <%s> x y" + expect_out "" + # Shell metacharacters are passed as plain words, never evaluated. + # shellcheck disable=SC2016 + run "${tree}" -- ::: echo '$(touch pwned)' ';' 'touch pwned2' + expect_rc 0 + [[ ! -e "${tree}/r1/pwned" && ! -e "${tree}/r1/pwned2" && ! -e "${tree}/pwned" ]] \ + || fail "${current}: command text was evaluated by a shell" + # shellcheck disable=SC2016 + expect_out '$(touch pwned) ; touch pwned2' +} + +test_error_propagation() { + new_tree errors good1 bad good2 + rm -f "${tree}/bad/name.txt" + # A failing command in one repository does not stop the others; its + # error output (stderr) is shown in that repository's block. + run "${tree}" --jobs 2 -- ::: cat name.txt + expect_out "::: bad" + expect_out "name.txt: No such file or directory" + expect_out "good1" + expect_out "good2" + # Historical behavior, kept for compatibility: the exit status is 0 even + # when the command fails in some or all repositories. + expect_rc 0 + run "${tree}" -- rev-parse --verify no-such-ref + expect_rc 0 + expect_out "fatal: Needed a single revision" + # A command that is not found is reported, not fatal. + run "${tree}" -- ::: git-parallel-no-such-command + expect_rc 0 + expect_out "::: good1" +} + +test_parallel_jobs() { + # Four jobs wait at a barrier until all four have started: only true + # parallelism with --jobs 4 gets them through. With --jobs 1 they run one + # at a time and each gives up after its timeout. + new_tree parallel p1 p2 p3 p4 + local barrier="${work}/barrier" + cat > "${work}/barrier.sh" <&1)"; rc=$? + expect_rc 1 + expect_out "Invalid job count: x" +} + +test_silent_and_color() { + new_tree silent r1 + run "${tree}" --silent -- status + expect_rc 0 + expect_eq "${out}" "" + run "${tree}" -q -- status + expect_eq "${out}" "" + run "${tree}" -q -v -- status + expect_out "::: r1" + run "${tree}" --color always -- status + expect_out $'\033[1;33mr1\033[0m' + run "${tree}" -c never -- status + expect_no_out $'\033[' + # Not a terminal: auto means no color. + run "${tree}" -c auto -- status + expect_no_out $'\033[' +} + +test_freeze() { + new_tree freeze hot cold + run "${tree}" --freeze "${tree}/cold" + expect_rc 1 + expect_out "${tree}/cold now frozen." + [[ -f "${tree}/cold/.git-parallel.freeze" ]] || fail "${current}: freeze file missing" + run "${tree}" -- ::: cat name.txt + expect_out "${tree}/cold is frozen." + expect_out "hot" + # The command did not run in the frozen repository. + ! grep -qx cold <<<"${out}" || fail "${current}: command ran in a frozen repository" + run "${tree}" -F "${work}/nowhere" + expect_err "is not git-repository." +} + +test_empty_tree() { + mkdir -p "${work}/empty" + run "${work}/empty" --list + expect_eq "${out}" "" + run "${work}/empty" -- status + expect_rc 0 + expect_out "start parallel: status" +} + +test_git_subcommand() { + # Installed on PATH, the script runs as `git parallel` through the bash + # under test (the shebang uses /usr/bin/env bash). + new_tree subcommand r1 r2 + local bin="${work}/bin" + mkdir -p "${bin}" + ln -sf "${GP}" "${bin}/git-parallel" + ln -sf "${TEST_BASH}" "${bin}/bash" + out="$(cd "${tree}" && PATH="${bin}:${PATH}" git parallel --jobs 2 -- log --format=%s 2>&1)"; rc=$? + expect_rc 0 + expect_out "init r1" + expect_out "init r2" + out="$(PATH="${bin}:${PATH}" git parallel --version 2>&1)"; rc=$? + expect_rc 1 + expect_out "git-parallel v" +} + +test_no_temp_files_left() { + new_tree tmpfiles r1 r2 r3 + local tmp="${work}/tmpdir" + mkdir -p "${tmp}" + out="$(cd "${tree}" && TMPDIR="${tmp}" "${TEST_BASH}" "${GP}" -j 2 -- status 2>&1)"; rc=$? + expect_rc 0 + [[ -z "$(ls -A "${tmp}")" ]] || fail "${current}: temporary files left: $(ls -A "${tmp}")" +} + +test_interrupt() { + # TERM stops git-parallel and the commands it started, and removes its + # temporary files. + new_tree interrupt r1 r2 + local tmp="${work}/tmp-int" marker="git-parallel-test-sleep-$$" pattern + mkdir -p "${tmp}" + ln -sf "$(command -v sleep)" "${work}/${marker}" + pattern="^${work}/${marker} 30" + (cd "${tree}" && TMPDIR="${tmp}" exec "${TEST_BASH}" "${GP}" -j 2 -q -- ::: "${work}/${marker}" 30) 2>"${work}/interrupt.stderr" & + local pid=$! i=0 + while [[ -z "$(pgrep -f "${pattern}" || true)" && ${i} -lt 50 ]]; do sleep 0.1; i=$((i + 1)); done + [[ -n "$(pgrep -f "${pattern}" || true)" ]] || { fail "${current}: commands did not start"; kill "${pid}" 2>/dev/null; return; } + kill -TERM "${pid}" + wait "${pid}" 2>/dev/null + i=0 + while [[ -n "$(pgrep -f "${pattern}" || true)" && ${i} -lt 50 ]]; do sleep 0.1; i=$((i + 1)); done + if [[ -n "$(pgrep -f "${pattern}" || true)" ]]; then + pkill -f "${pattern}" 2>/dev/null + fail "${current}: commands still running after TERM" + fi + [[ -z "$(ls -A "${tmp}")" ]] || fail "${current}: temporary files left: $(ls -A "${tmp}")" +} + +# shellcheck disable=SC2016 +echo "git-parallel tests with $("${TEST_BASH}" -c 'echo "bash ${BASH_VERSION}"') (${TEST_BASH}), $(git --version)" + +t "--version / -V" test_version +t "--help / -h / no command" test_help +t "unknown options and invalid values" test_unknown_options +t "--list" test_list +t "--maxdepth" test_maxdepth +t "-d / --target / --targets" test_targets +t "git commands" test_git_command +t "::: bare commands" test_bare_command +t "output order" test_output_order +t "paths with spaces" test_paths_with_spaces +t "argument quoting" test_argument_quoting +t "error propagation and exit status" test_error_propagation +t "--jobs runs jobs in parallel" test_parallel_jobs +t "--jobs 0 is rejected" test_jobs_zero +t "--silent / --verbose / --color" test_silent_and_color +t "--freeze" test_freeze +t "no repositories" test_empty_tree +t "git parallel (git subcommand)" test_git_subcommand +t "temporary files are removed" test_no_temp_files_left +t "TERM stops running commands" test_interrupt + +echo +echo "${passed} passed, ${failed} failed" +[[ "${failed}" == 0 ]] From e9bf5a896c65ab9c1817c8efb96891a74618a46a Mon Sep 17 00:00:00 2001 From: Hiroshi IKEGAMI Date: Sun, 25 Oct 2026 17:56:18 +0900 Subject: [PATCH 2/6] ci: check the exit status of the --version/--help smoke test explicitly Claude-Session: https://claude.ai/code/session_01RbGbwRHvCEhsKLZfbvbbRg --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08c1feb..b48a674 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,8 +84,9 @@ jobs: env: TEST_BASH: ${{ steps.bash.outputs.path }} run: | - set -uo pipefail - # --version and --help exit 1 (historical behavior). + # The default shell runs with -e; --version and --help exit 1 + # (historical behavior), so check their status explicitly. + set +e -u out="$("$TEST_BASH" ./git-parallel --version)"; rc=$? echo "$out" [[ $rc == 1 && "$out" =~ ^git-parallel\ v[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1 From c83547e14c403fcc388f6eeac1b869b7a10f996d Mon Sep 17 00:00:00 2001 From: Hiroshi IKEGAMI Date: Sun, 25 Oct 2026 20:05:17 +0900 Subject: [PATCH 3/6] ci: verify the unreleased Hephaestus v1.1.1 fix (temporary pin) Pins the Hephaestus action and CLI to 4414722 (magicdrive/hephaestus#22, pseudo-version v1.1.1-0.20261025105032-4414722a804d): the action no longer puts its own Go on PATH or exports GOTOOLCHAIN=local, and redaction no longer hides paths under PWD. This commit only verifies the fix in this repository's CI; it is reverted by the next commit, which returns to the released v1.1.0. Pin v1.1.1 once it is released. Claude-Session: https://claude.ai/code/session_01RbGbwRHvCEhsKLZfbvbbRg --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 8 ++++---- Makefile | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b48a674..14fa05f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,13 +103,13 @@ jobs: with: persist-credentials: false - name: Check configuration - uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 + uses: magicdrive/hephaestus@4414722a804d958179fd547c26d7b2fe762e308e # v1.1.1-0.20261025105032-4414722a804d with: command: check github-token: "" - name: Snapshot (archive, checksum) id: snapshot - uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 + uses: magicdrive/hephaestus@4414722a804d958179fd547c26d7b2fe762e308e # v1.1.1-0.20261025105032-4414722a804d with: command: run stages: archive,checksum diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d00245..eab7193 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: TAG: ${{ github.ref_name }} run: scripts/check-version.sh "$TAG" - name: Archive and checksum - uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 + uses: magicdrive/hephaestus@4414722a804d958179fd547c26d7b2fe762e308e # v1.1.1-0.20261025105032-4414722a804d with: command: run stages: archive,checksum @@ -92,7 +92,7 @@ jobs: # Checks the tap token, the tap layout, a newer formula on the tap # and the pull request branch before the release becomes public, # since a published release cannot be taken back. - uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 + uses: magicdrive/hephaestus@4414722a804d958179fd547c26d7b2fe762e308e # v1.1.1-0.20261025105032-4414722a804d with: command: run stages: formula,tap @@ -106,12 +106,12 @@ jobs: dist/git-parallel_*_noarch.tar.gz dist/git-parallel_*_checksums.txt - name: Publish the GitHub release - uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 + uses: magicdrive/hephaestus@4414722a804d958179fd547c26d7b2fe762e308e # v1.1.1-0.20261025105032-4414722a804d with: command: release - name: Generate the formula and open a tap pull request # Prereleases (vX.Y.Z-rc.N) are skipped by brews.skip_upload: auto. - uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 + uses: magicdrive/hephaestus@4414722a804d958179fd547c26d7b2fe762e308e # v1.1.1-0.20261025105032-4414722a804d with: command: run stages: formula,tap diff --git a/Makefile b/Makefile index e29f915..3dd72f7 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ SHELLCHECK ?= shellcheck SHELL_SOURCES := git-parallel tests/run.sh scripts/check-version.sh scripts/homebrew-e2e.sh # renovate: datasource=go depName=github.com/magicdrive/hephaestus -HEPHAESTUS_VERSION ?= v1.1.0 +HEPHAESTUS_VERSION ?= v1.1.1-0.20261025105032-4414722a804d HEPHAESTUS ?= go run github.com/magicdrive/hephaestus@$(HEPHAESTUS_VERSION) # renovate: datasource=go depName=github.com/rhysd/actionlint ACTIONLINT_VERSION ?= v1.7.12 From 8ef8f0414a39b5edcffcb8e6f02fb3969ff88ac6 Mon Sep 17 00:00:00 2001 From: Hiroshi IKEGAMI Date: Sun, 25 Oct 2026 20:36:15 +0900 Subject: [PATCH 4/6] Revert "ci: verify the unreleased Hephaestus v1.1.1 fix (temporary pin)" This reverts commit c83547e14c403fcc388f6eeac1b869b7a10f996d. Its CI run verified the Hephaestus action and CLI of magicdrive/hephaestus#22 in this repository; the pull request goes back to the released v1.1.0, so that it never asks to merge an unreleased pin. Pin v1.1.1 (its tag's commit) once it is released. Claude-Session: https://claude.ai/code/session_01RbGbwRHvCEhsKLZfbvbbRg --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 8 ++++---- Makefile | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14fa05f..b48a674 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,13 +103,13 @@ jobs: with: persist-credentials: false - name: Check configuration - uses: magicdrive/hephaestus@4414722a804d958179fd547c26d7b2fe762e308e # v1.1.1-0.20261025105032-4414722a804d + uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 with: command: check github-token: "" - name: Snapshot (archive, checksum) id: snapshot - uses: magicdrive/hephaestus@4414722a804d958179fd547c26d7b2fe762e308e # v1.1.1-0.20261025105032-4414722a804d + uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 with: command: run stages: archive,checksum diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eab7193..0d00245 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: TAG: ${{ github.ref_name }} run: scripts/check-version.sh "$TAG" - name: Archive and checksum - uses: magicdrive/hephaestus@4414722a804d958179fd547c26d7b2fe762e308e # v1.1.1-0.20261025105032-4414722a804d + uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 with: command: run stages: archive,checksum @@ -92,7 +92,7 @@ jobs: # Checks the tap token, the tap layout, a newer formula on the tap # and the pull request branch before the release becomes public, # since a published release cannot be taken back. - uses: magicdrive/hephaestus@4414722a804d958179fd547c26d7b2fe762e308e # v1.1.1-0.20261025105032-4414722a804d + uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 with: command: run stages: formula,tap @@ -106,12 +106,12 @@ jobs: dist/git-parallel_*_noarch.tar.gz dist/git-parallel_*_checksums.txt - name: Publish the GitHub release - uses: magicdrive/hephaestus@4414722a804d958179fd547c26d7b2fe762e308e # v1.1.1-0.20261025105032-4414722a804d + uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 with: command: release - name: Generate the formula and open a tap pull request # Prereleases (vX.Y.Z-rc.N) are skipped by brews.skip_upload: auto. - uses: magicdrive/hephaestus@4414722a804d958179fd547c26d7b2fe762e308e # v1.1.1-0.20261025105032-4414722a804d + uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 with: command: run stages: formula,tap diff --git a/Makefile b/Makefile index 3dd72f7..e29f915 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ SHELLCHECK ?= shellcheck SHELL_SOURCES := git-parallel tests/run.sh scripts/check-version.sh scripts/homebrew-e2e.sh # renovate: datasource=go depName=github.com/magicdrive/hephaestus -HEPHAESTUS_VERSION ?= v1.1.1-0.20261025105032-4414722a804d +HEPHAESTUS_VERSION ?= v1.1.0 HEPHAESTUS ?= go run github.com/magicdrive/hephaestus@$(HEPHAESTUS_VERSION) # renovate: datasource=go depName=github.com/rhysd/actionlint ACTIONLINT_VERSION ?= v1.7.12 From 8cfd351be2f55a13576ceb008078197909fef8f3 Mon Sep 17 00:00:00 2001 From: Hiroshi IKEGAMI Date: Sun, 25 Oct 2026 22:34:50 +0900 Subject: [PATCH 5/6] build: move to Hephaestus v1.2.0-rc.1 and release with make publish Pin every magicdrive/hephaestus action to the v1.2.0-rc.1 tag commit (bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e) and HEPHAESTUS_VERSION to v1.2.0-rc.1. From v1.2.0 the action downloads and verifies its release binary and sets up no Go: the release workflow and the hephaestus CI job need none, and the jobs that run Go tools (actionlint, go install for the Homebrew end-to-end test) keep their own setup-go. Follow the hephaestus publish conventions in release.yml: a plan step (id: plan, command: plan) and the tap token check, the read-only tap rehearsal and the tap pull request step run only when steps.plan.outputs.tap == 'publish', so a prerelease never receives the tap token. CI plans a release candidate and a stable release and checks that only the stable one publishes the tap. Add make publish / publish-check / publish-status / publish-watch / publish-help, wired as in Hephaestus's own Makefile: the version reaches the tool through HEPHAESTUS_PUBLISH_* environment variables, never a shell command line. publish and publish-check first run scripts/check-version.sh --from-make, so a tag whose version git-parallel does not report (and the release workflow would refuse) is never pushed. tests/publish-make.sh checks the wiring with a stub tool and runs in CI on GNU Make 4 (Linux) and 3.81 (macOS). Claude-Session: https://claude.ai/code/session_01RbGbwRHvCEhsKLZfbvbbRg --- .github/workflows/ci.yml | 33 +++++++-- .github/workflows/release.yml | 28 ++++++-- Makefile | 93 ++++++++++++++++++++++-- README.md | 6 +- docs/development/releasing.md | 120 +++++++++++++++++++++---------- hephaestus.yaml | 10 +-- scripts/check-version.sh | 42 +++++++++-- tests/publish-make.sh | 131 ++++++++++++++++++++++++++++++++++ 8 files changed, 395 insertions(+), 68 deletions(-) create mode 100755 tests/publish-make.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b48a674..485b517 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - # No go.mod here: Go only runs pinned tools (actionlint, hephaestus). + # No go.mod here: Go only runs a pinned tool (actionlint). go-version: stable cache: false - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 @@ -80,6 +80,10 @@ jobs: env: TEST_BASH: ${{ steps.bash.outputs.path }} run: make test TEST_BASH="$TEST_BASH" + - name: make publish wiring + # The publish targets with a stub in place of hephaestus: no git or + # GitHub access. GNU Make 4 on Linux, 3.81 on macOS. + run: make test-publish - name: Smoke (--version / --help) env: TEST_BASH: ${{ steps.bash.outputs.path }} @@ -95,7 +99,8 @@ jobs: hephaestus: # The release packaging, as the release workflow runs it, without a tag - # or any token: configuration check, then archive and checksum. + # or any token: configuration check, release plan, then archive and + # checksum. No Go: the action runs its verified release binary. runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -103,13 +108,32 @@ jobs: with: persist-credentials: false - name: Check configuration - uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 + uses: magicdrive/hephaestus@bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e # v1.2.0-rc.1 with: command: check github-token: "" + - name: Plan a release candidate + id: plan-rc + uses: magicdrive/hephaestus@bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e # v1.2.0-rc.1 + with: + command: plan + tag: v0.0.0-rc.1 + - name: Plan a stable release + id: plan-stable + uses: magicdrive/hephaestus@bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e # v1.2.0-rc.1 + with: + command: plan + tag: v0.0.0 + - name: A release candidate never updates the tap + env: + RC: ${{ steps.plan-rc.outputs.prerelease }}/${{ steps.plan-rc.outputs.tap }} + STABLE: ${{ steps.plan-stable.outputs.prerelease }}/${{ steps.plan-stable.outputs.tap }} + run: | + echo "rc: $RC, stable: $STABLE" + [[ "$RC" == true/skip && "$STABLE" == false/publish ]] - name: Snapshot (archive, checksum) id: snapshot - uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 + uses: magicdrive/hephaestus@bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e # v1.2.0-rc.1 with: command: run stages: archive,checksum @@ -146,6 +170,7 @@ jobs: persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: + # make homebrew-e2e installs the hephaestus CLI with go install. go-version: stable cache: false - name: Put Homebrew on PATH (Linux) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d00245..2e061d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,8 @@ jobs: build: # Packages the release with a read-only token, no environment and no # secrets. git-parallel is a script: there is no build stage, only the - # noarch archive and its checksum. Only the verified dist/ moves on. + # noarch archive and its checksum, and no toolchain: the action runs its + # verified release binary. Only the verified dist/ moves on. runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -37,7 +38,7 @@ jobs: TAG: ${{ github.ref_name }} run: scripts/check-version.sh "$TAG" - name: Archive and checksum - uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 + uses: magicdrive/hephaestus@bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e # v1.2.0-rc.1 with: command: run stages: archive,checksum @@ -64,7 +65,9 @@ jobs: release: # Publishes the verified dist/ from the build job; nothing is built here. # The environment holds HOMEBREW_TAP_GITHUB_TOKEN; its deployment policy - # must allow tags v* (see docs/development/releasing.md). + # must allow tags v* only (see docs/development/releasing.md). The tap + # token reaches only the two tap steps, and only when the plan publishes + # the tap: a prerelease (vX.Y.Z-rc.N) never receives it. needs: [ci, build] runs-on: ubuntu-latest timeout-minutes: 20 @@ -77,11 +80,19 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Plan the release + # prerelease and tap for this tag, from hephaestus.yaml. Fails for a + # prerelease whose plan would update the tap. + id: plan + uses: magicdrive/hephaestus@bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e # v1.2.0-rc.1 + with: + command: plan - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: dist path: dist - name: The tap token is available + if: steps.plan.outputs.tap == 'publish' # Without a token the dry run below cannot read the tap and would # pass. Only whether it is set reaches this step, never its value. env: @@ -89,10 +100,11 @@ jobs: run: | [[ "$HAS_TAP_TOKEN" == true ]] || { echo "::error::HOMEBREW_TAP_GITHUB_TOKEN is not set in the release environment"; exit 1; } - name: Rehearse the formula and tap pull request (read-only) + if: steps.plan.outputs.tap == 'publish' # Checks the tap token, the tap layout, a newer formula on the tap # and the pull request branch before the release becomes public, # since a published release cannot be taken back. - uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 + uses: magicdrive/hephaestus@bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e # v1.2.0-rc.1 with: command: run stages: formula,tap @@ -106,12 +118,14 @@ jobs: dist/git-parallel_*_noarch.tar.gz dist/git-parallel_*_checksums.txt - name: Publish the GitHub release - uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 + uses: magicdrive/hephaestus@bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e # v1.2.0-rc.1 with: command: release - name: Generate the formula and open a tap pull request - # Prereleases (vX.Y.Z-rc.N) are skipped by brews.skip_upload: auto. - uses: magicdrive/hephaestus@550d48f64917749742574b5c2ff1b292940ff71a # v1.1.0 + # Prereleases (vX.Y.Z-rc.N) skip the tap (brews.skip_upload: auto), + # so the plan skips this step. + if: steps.plan.outputs.tap == 'publish' + uses: magicdrive/hephaestus@bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e # v1.2.0-rc.1 with: command: run stages: formula,tap diff --git a/Makefile b/Makefile index e29f915..03dd752 100644 --- a/Makefile +++ b/Makefile @@ -21,24 +21,27 @@ uninstall: # Bash used to run the tests; TEST_BASH=/bin/bash checks macOS's bash 3.2. TEST_BASH ?= bash SHELLCHECK ?= shellcheck -SHELL_SOURCES := git-parallel tests/run.sh scripts/check-version.sh scripts/homebrew-e2e.sh +SHELL_SOURCES := git-parallel tests/run.sh tests/publish-make.sh scripts/check-version.sh \ + scripts/homebrew-e2e.sh # renovate: datasource=go depName=github.com/magicdrive/hephaestus -HEPHAESTUS_VERSION ?= v1.1.0 +HEPHAESTUS_VERSION ?= v1.2.0-rc.1 HEPHAESTUS ?= go run github.com/magicdrive/hephaestus@$(HEPHAESTUS_VERSION) # renovate: datasource=go depName=github.com/rhysd/actionlint ACTIONLINT_VERSION ?= v1.7.12 # renovate: datasource=npm depName=renovate RENOVATE_VERSION ?= 44.149.0 -.PHONY: all install uninstall help lint test check actionlint renovate-check \ - hephaestus-check snapshot version-check preflight release-check homebrew-e2e +.PHONY: all install uninstall help lint test test-publish check actionlint \ + renovate-check hephaestus-check snapshot version-check preflight release-check \ + homebrew-e2e help: @echo "make install|uninstall install to / remove from PREFIX ($(PREFIX))" @echo "make lint shellcheck the scripts" @echo "make test [TEST_BASH=...] run tests/run.sh with the given bash" - @echo "make check lint + test" + @echo "make test-publish test the make publish wiring (no git or GitHub access)" + @echo "make check lint + test + test-publish" @echo "make actionlint lint the GitHub Actions workflows" @echo "make renovate-check validate renovate.json (needs npx)" @echo "make hephaestus-check validate hephaestus.yaml" @@ -47,6 +50,11 @@ help: @echo "make preflight tag=vX.Y.Z version-check + hephaestus preflight --strict before tagging" @echo "make release-check tag=vX.Y.Z check + hephaestus-check + snapshot + preflight" @echo "make homebrew-e2e package, then brew style/audit/install/test/uninstall (CI)" + @echo "make publish vX.Y.Z release: version check, preflight, confirm, tag, push, follow, verify" + @echo "make publish-check vX.Y.Z version check + release preflight only (read-only)" + @echo "make publish-status vX.Y.Z what exists for a version (read-only)" + @echo "make publish-watch vX.Y.Z follow the release run of an already pushed tag" + @echo "make publish-help how publishing works" lint: $(SHELLCHECK) $(SHELL_SOURCES) @@ -54,7 +62,10 @@ lint: test: TEST_BASH=$(TEST_BASH) tests/run.sh -check: lint test +test-publish: + tests/publish-make.sh + +check: lint test test-publish actionlint: go run github.com/rhysd/actionlint/cmd/actionlint@$(ACTIONLINT_VERSION) @@ -83,3 +94,73 @@ release-check: check hephaestus-check snapshot preflight homebrew-e2e: GOBIN="$(CURDIR)/dist/.bin" go install github.com/magicdrive/hephaestus@$(HEPHAESTUS_VERSION) HEPHAESTUS="$(CURDIR)/dist/.bin/hephaestus" scripts/homebrew-e2e.sh + +# --------------------------------------------------------------------------- +# Release with Hephaestus (its docs/publish.md): +# make publish v1.2.3 (or VERSION=v1.2.3, or tag=v1.2.3) +# make publish-check v1.2.3 version check + preflight only +# make publish-status v1.2.3 what exists for a version +# make publish-watch v1.2.3 follow an already pushed tag +# make publish-help +# The version given as a goal is a no-op goal (.DEFAULT). It reaches +# scripts/check-version.sh and hephaestus through the environment, never +# through a shell command line, and both validate it. YES=1 on the command +# line skips the prompt. The release workflow is the only publisher; publish +# only pushes the tag after its checks, then follows and verifies the run. +# +# The version is written in git-parallel (--version), so publish and +# publish-check first require it to be exactly the version being released: +# a tag the release workflow would refuse is never pushed. +PUBLISH ?= $(HEPHAESTUS) publish +VERSION_CHECK ?= scripts/check-version.sh +PUBLISH_TARGETS = publish publish-check publish-status publish-watch publish-help + +ifneq ($(filter $(PUBLISH_TARGETS),$(MAKECMDGOALS)),) +PUBLISH_GOAL := $(firstword $(MAKECMDGOALS)) +PUBLISH_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) +ifeq ($(filter $(PUBLISH_TARGETS),$(PUBLISH_GOAL)),) +$(error publish targets come first and alone: make publish v1.2.3) +endif +ifneq ($(filter $(PUBLISH_TARGETS),$(PUBLISH_ARGS)),) +$(error give one publish target at a time) +endif +ifneq ($(word 2,$(PUBLISH_ARGS)),) +$(error one version is expected, got: $(PUBLISH_ARGS)) +endif +ifeq ($(PUBLISH_GOAL),publish-help) +ifneq ($(PUBLISH_ARGS),) +$(error publish-help takes no version) +endif +endif +export HEPHAESTUS_PUBLISH_ARGS := $(PUBLISH_ARGS) +export HEPHAESTUS_PUBLISH_VERSION := $(if $(filter command line,$(origin VERSION)),$(VERSION)) +export HEPHAESTUS_PUBLISH_TAG := $(if $(filter command line,$(origin tag)),$(tag)) +PUBLISH_YES := $(if $(and $(filter command line,$(origin YES)),$(filter 1,$(YES))),-yes) +ifneq ($(PUBLISH_ARGS),) +ifeq ($(filter v0% v1% v2% v3% v4% v5% v6% v7% v8% v9% 0% 1% 2% 3% 4% 5% 6% 7% 8% 9%,$(PUBLISH_ARGS)),) +$(error "$(PUBLISH_ARGS)" is not a version: make $(PUBLISH_GOAL) v1.2.3) +endif +# The version goal has no rule of its own: it is the publish target's +# argument. (No other target may start with v.) +.DEFAULT: + @: +endif +endif + +.PHONY: publish publish-check publish-status publish-watch publish-help +publish: + @$(VERSION_CHECK) --from-make + @$(PUBLISH) publish -from-make $(PUBLISH_YES) +# Reports both the version check and the preflight; an invalid or missing +# version (exit 2) stops before the preflight. +publish-check: + @rc=0; $(VERSION_CHECK) --from-make || rc=$$?; \ + if [ "$$rc" -ge 2 ]; then exit "$$rc"; fi; \ + $(PUBLISH) check -from-make || exit $$?; \ + exit "$$rc" +publish-status: + @$(PUBLISH) status -from-make +publish-watch: + @$(PUBLISH) watch -from-make +publish-help: + @$(PUBLISH) help diff --git a/README.md b/README.md index f65dc9f..91f3db2 100644 --- a/README.md +++ b/README.md @@ -54,12 +54,12 @@ Development ----------- ``` -make check # shellcheck + tests +make check # shellcheck + tests + make publish wiring make test TEST_BASH=/bin/bash # tests with a specific bash ``` -Releases are made with [Hephaestus](https://github.com/magicdrive/hephaestus): -see [docs/development/releasing.md](docs/development/releasing.md). +Releases are made with [Hephaestus](https://github.com/magicdrive/hephaestus) +(`make publish vX.Y.Z`): see [docs/development/releasing.md](docs/development/releasing.md). Author diff --git a/docs/development/releasing.md b/docs/development/releasing.md index c337bf9..76596f3 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -2,72 +2,104 @@ Releases are built and published by [Hephaestus](https://github.com/magicdrive/hephaestus) (`hephaestus.yaml`) from `.github/workflows/release.yml` when a `v*` tag is -pushed. git-parallel is a Bash script, so a release is one -architecture-independent archive, `git-parallel__noarch.tar.gz` -(`git-parallel`, `git-parallel.1`, `README.md`), its checksum file -`git-parallel__checksums.txt`, build provenance attestations, and a -pull request to [magicdrive/homebrew-tap](https://github.com/magicdrive/homebrew-tap) -for the `git-parallel` formula. +pushed. **That workflow is the only thing that publishes.** `make publish +vX.Y.Z` is the way to push the tag: it checks that the version can be +released, asks, creates and pushes one annotated tag, then follows the +release run and verifies what it published (Hephaestus `hephaestus publish`, +its `docs/publish.md`). It never publishes, uploads or writes to GitHub +itself. + +git-parallel is a Bash script, so a release is one architecture-independent +archive, `git-parallel__noarch.tar.gz` (`git-parallel`, +`git-parallel.1`, `README.md`), its checksum file +`git-parallel__checksums.txt`, build provenance attestations for +both, and, for a stable version, a pull request to +[magicdrive/homebrew-tap](https://github.com/magicdrive/homebrew-tap) for the +`git-parallel` formula. ## One-time setup (repository owner) -- **Environment `release`** holds the secret `HOMEBREW_TAP_GITHUB_TOKEN`: a - fine-grained token (or GitHub App token) with `contents: write` and - `pull-requests: write` on `magicdrive/homebrew-tap` only. -- **Its deployment policy must allow tags `v*`.** The release job runs for a - tag push; a policy that lists only *branches* `v*` rejects it before any - step runs. Add a tag policy `v*`, and remove the branch policy `v*` - (`hephaestus preflight` reports a branch policy as a failure: a branch - named like a version could read the release secret). -- Recommended: a ruleset that protects `main`, and a tag ruleset on `v*` - that forbids updating and deleting tags (see the Hephaestus - `docs/preflight.md#recommended-settings`). +`make publish-check vX.Y.Z` checks each of these it can read (secret names +only, never values). + +| Item | Value | +| --- | --- | +| Ruleset on `main` | Active; requires the status check **`ci-gate`** (the job in `ci.yml` that needs every other job) | +| Ruleset on tags `refs/tags/v*` | Active; forbids **deletion** and **update** of tags (not creation) | +| Environment `release` | Deployment branches and tags: selected policies, **tags `v*` only**, no branch policy. A branch policy `v*` fails the check: a branch named like a version could read the release secret | +| Secret in `release` | `HOMEBREW_TAP_GITHUB_TOKEN`: a fine-grained token (or GitHub App token) with `contents: write` and `pull-requests: write` on `magicdrive/homebrew-tap` only. Only stable releases receive it | +| Locally | `git`, an authenticated `gh` (`gh auth status`), Go (the Makefile runs `go run github.com/magicdrive/hephaestus@$(HEPHAESTUS_VERSION)`; `HEPHAESTUS=/path/to/hephaestus` uses an installed v1.2.0 or later instead), GNU Make 3.81 or later | ## Steps 1. **Set the version.** The version is written in the script (`git::parallel::version`, printed by `git parallel --version`); nothing rewrites it at release time. Change it to the new version in a pull - request and merge it after CI passes. -2. **Check before tagging**, on an up-to-date `main` with a clean tree: + request and merge it after CI passes (CI on `main` must have succeeded + for exactly the commit being tagged). +2. **Check** (optional; `make publish` runs the same checks), on an + up-to-date `main` with a clean tree: ```sh - make preflight tag=vX.Y.Z + make publish-check vX.Y.Z ``` - This checks that `git-parallel --version` prints `git-parallel vX.Y.Z`, - then runs `hephaestus preflight --tag vX.Y.Z --strict` (read-only: - repository state, configuration, workflow, environment, secret names, - tap). It needs a GitHub token that can read the repository (for example - `GITHUB_TOKEN="$(gh auth token)"`). `make release-check tag=vX.Y.Z` also - runs the tests and a local snapshot. -3. **Push the tag:** + It first checks that `git-parallel --version` prints + `git-parallel vX.Y.Z` (`scripts/check-version.sh`), then runs the + Hephaestus publish preflight: branch, clean and pushed tree, tag not yet + used, CI on the commit, release workflow, rulesets, environment, secret + names, the tap, and `hephaestus preflight --strict`. Read-only, apart + from a fetch of `origin/main`. It reports both and fails if either fails. +3. **Publish:** ```sh - git tag vX.Y.Z - git push origin vX.Y.Z + make publish vX.Y.Z # or VERSION=vX.Y.Z, or tag=vX.Y.Z + make publish vX.Y.Z YES=1 # without the confirmation prompt ``` + The version check runs first, so a tag that the release workflow would + refuse is never pushed. Then the preflight, a confirmation (default No), + one annotated tag pushed to `origin`, and the release run is followed + and verified: assets, attestations, release notes and, for a stable + version, the tap pull request. 4. **The workflow** runs the full CI on the tagged commit, then - `build` (read-only, no secrets): refuses a tag that differs from the script's version, archives, checksums and verifies `dist/`; - - `release` (environment `release`): rehearses the tap pull request - (dry run), attests the archive and checksum file, publishes the GitHub - release (notes generated by GitHub), then opens the tap pull request. - Prereleases (`vX.Y.Z-rc.N`) are released but do not update the tap. + - `release` (environment `release`): plans the release (`prerelease`, + `tap`), rehearses the tap pull request (dry run), attests the archive + and checksum file, publishes the GitHub release (notes generated by + GitHub), then opens the tap pull request. For a prerelease + (`vX.Y.Z-rc.N`) the plan says `tap=skip`: the release is published as a + prerelease, both tap steps are skipped and never receive the tap token. 5. **Review and merge the tap pull request** ("Brew formula update for - git-parallel version vX.Y.Z"). The first one adds `git-parallel.rb`; - after it is merged: + git-parallel version vX.Y.Z"). `make publish` verifies it but never + merges it. The first one adds `git-parallel.rb`; after it is merged: ```sh brew install magicdrive/tap/git-parallel ``` +| Command | What it does | +| --- | --- | +| `make publish vX.Y.Z` | version check, preflight, confirm, tag, push, follow, verify | +| `make publish-check vX.Y.Z` | version check and preflight only | +| `make publish-status vX.Y.Z` | what exists for a version: tag, run, release, verification (read-only) | +| `make publish-watch vX.Y.Z` | find and follow the release run of an already pushed tag, then verify | +| `make publish-help` | how publishing works and its safety rules | + +`make version-check tag=vX.Y.Z`, `make preflight tag=vX.Y.Z` (version check + +`hephaestus preflight --strict`) and `make release-check tag=vX.Y.Z` (tests, +configuration, snapshot, preflight) are still available as read-only +checks. Before `make publish` existed, the tag was created and pushed by +hand with `git tag` / `git push`; do not do that any more: `make publish` +checks everything first and verifies the result. + ## Local checks ```sh -make check # shellcheck + tests (bash on PATH) +make check # shellcheck + tests (bash on PATH) + make publish wiring make test TEST_BASH=/bin/bash # tests with macOS's bash 3.2 +make test-publish # the publish targets with a stub tool (no git or GitHub) make hephaestus-check # validate hephaestus.yaml make snapshot # dist/git-parallel_0.0.0-SNAPSHOT_noarch.tar.gz + checksum ``` @@ -80,8 +112,18 @@ prefer a container such as `homebrew/brew`. ## When something fails -Nothing is ever fixed by moving or deleting a tag. Hephaestus resumes a -release by running the same thing again (Hephaestus `docs/recovery.md`): +Nothing is ever fixed by moving or deleting a tag. An interrupted +`make publish` (Ctrl-C, network, timeout; exit status 3) stopped only the +monitoring; the release keeps running on GitHub: + +```sh +make publish-status vX.Y.Z # tag, run, release, assets, attestations, tap +make publish-watch vX.Y.Z # find and follow the run again, then verify +``` + +Running `make publish` again for a version whose tag exists is refused. +Hephaestus resumes a release by running the same thing again (Hephaestus +`docs/recovery.md`): - **Before the release is published** (CI, `build`, the tap rehearsal or the attestation failed): nothing is public. Fix the cause; if the fix needs a @@ -90,7 +132,7 @@ release by running the same thing again (Hephaestus `docs/recovery.md`): above) and re-run the failed jobs. - **Publishing stopped halfway**: re-run the failed `release` job. Identical assets are skipped, a draft is reused; an asset that differs is never - overwritten (`hephaestus status --tag vX.Y.Z` shows the state). + overwritten. - **The tap pull request failed after publishing**: re-run the failed `release` job (within the 7 days the `dist` artifact is kept): the release step finds everything already published and the tap step resumes; an diff --git a/hephaestus.yaml b/hephaestus.yaml index e2d888e..82de417 100644 --- a/hephaestus.yaml +++ b/hephaestus.yaml @@ -6,10 +6,11 @@ # and ARM, from one url. # # make snapshot # archive + checksum locally, no tag needed -# make preflight tag=vX.Y.Z # release safety checks before pushing a tag +# make publish-check vX.Y.Z # version check + release preflight (read-only) +# make publish vX.Y.Z # checks, then tags, pushes, follows, verifies # # The release workflow (.github/workflows/release.yml) runs on a v* tag -# after CI passes. See docs/development/releasing.md. +# after CI passes and is the only publisher. See docs/development/releasing.md. version: 1 project: @@ -64,8 +65,9 @@ brews: pull_request: enabled: true -# Release safety checks before a tag is pushed: make preflight tag=vX.Y.Z -# (hephaestus preflight, see the Hephaestus docs/preflight.md). +# Release safety checks before a tag is pushed: make publish-check vX.Y.Z +# runs them (hephaestus preflight --strict, see the Hephaestus +# docs/preflight.md); make preflight tag=vX.Y.Z runs them alone. preflight: workflow: release.yml environment: release diff --git a/scripts/check-version.sh b/scripts/check-version.sh index b647e12..b46a440 100755 --- a/scripts/check-version.sh +++ b/scripts/check-version.sh @@ -1,17 +1,49 @@ #!/usr/bin/env bash # Usage: scripts/check-version.sh vX.Y.Z +# scripts/check-version.sh --from-make # # The version is written into git-parallel (git::parallel::version, printed # by --version / -V) and is not generated at release time. This check fails # unless the script reports exactly the tag being released, so a release # cannot ship a script that prints another version. The release workflow -# runs it before anything is packaged; make preflight runs it before tagging. +# runs it before anything is packaged; make publish and make publish-check +# run it before the tag is pushed (make version-check and make preflight +# too). +# +# --from-make reads the version the publish targets of the Makefile export +# (HEPHAESTUS_PUBLISH_ARGS, _VERSION or _TAG; exactly one must be set), so +# the version never passes through a shell command line. +# +# Exit status: 0 the script reports the tag, 1 it reports another version, +# 2 usage (no version, more than one, or not a vMAJOR.MINOR.PATCH[-PRE] tag). set -euo pipefail -tag="${1:?usage: $0 vX.Y.Z}" -if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "error: ${tag} is not a vMAJOR.MINOR.PATCH[-PRERELEASE] tag" >&2 - exit 1 +usage() { echo "usage: $0 vX.Y.Z | --from-make" >&2; exit 2; } + +[[ $# -eq 1 ]] || usage +if [[ "$1" == --from-make ]]; then + tag="" given=0 + for name in HEPHAESTUS_PUBLISH_ARGS HEPHAESTUS_PUBLISH_VERSION HEPHAESTUS_PUBLISH_TAG; do + if [[ -n "${!name:-}" ]]; then + tag="${!name}" + given=$((given + 1)) + fi + done + if [[ "${given}" -ne 1 ]]; then + echo "error: give exactly one version: make publish vX.Y.Z (or VERSION=vX.Y.Z, or tag=vX.Y.Z)" >&2 + exit 2 + fi +else + tag="$1" +fi + +# A SemVer 2.0.0 version with a leading v and no build metadata, as +# hephaestus publish requires. +num='(0|[1-9][0-9]*)' +pre='(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)' +if [[ ! "${tag}" =~ ^v${num}\.${num}\.${num}(-${pre}(\.${pre})*)?$ ]]; then + echo "error: \"${tag}\" is not a vMAJOR.MINOR.PATCH[-PRERELEASE] tag" >&2 + exit 2 fi script="$(cd "$(dirname "$0")/.." && pwd)/git-parallel" diff --git a/tests/publish-make.sh b/tests/publish-make.sh new file mode 100755 index 0000000..8c990ee --- /dev/null +++ b/tests/publish-make.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Tests for the make publish targets (Makefile, scripts/check-version.sh). +# +# The publish tool is replaced by a stub (PUBLISH=...) that records how it +# was called, so no git or GitHub access happens: argument handling, the +# version check that runs before the tool, make -n and make -j. +# +# tests/publish-make.sh (or: make test-publish) +# +# Bash 3.2 compatible (macOS /bin/bash with GNU Make 3.81). +set -u + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +work="$(cd "$(mktemp -d "${TMPDIR:-/tmp}/git-parallel-publish.XXXXXX")" && pwd)" +trap 'rm -rf "${work}"' EXIT + +# A clean make environment: no MAKEFLAGS or jobserver from a parent +# `make check`, and no VERSION/YES/tag unless a case sets them. +unset MAKEFLAGS MFLAGS MAKELEVEL MAKEOVERRIDES VERSION YES tag \ + HEPHAESTUS_PUBLISH_ARGS HEPHAESTUS_PUBLISH_VERSION HEPHAESTUS_PUBLISH_TAG + +log="${work}/calls" +stub="${work}/stub" +cat > "${stub}" <> "${log}" +EOF +chmod +x "${stub}" + +# The version git-parallel reports: the only one make publish accepts. +cur="$(bash "${repo_root}/git-parallel" --version 2>&1 || true)" +cur="${cur#git-parallel }" +other="v99.0.0" +[[ "${cur}" != "${other}" ]] || { echo "git-parallel reports ${other}; pick another version" >&2; exit 2; } + +passed=0 failed=0 +out="" rc=0 calls="" + +# mk [VAR=value ...] -- make arguments...: runs make in the repository with +# the stub and the given environment; sets out, rc and calls. +mk() { + local env=() + while [[ $# -gt 0 && "$1" != -- ]]; do env+=("$1"); shift; done + shift + rm -f "${log}" + out="$(cd "${repo_root}" && env ${env[@]+"${env[@]}"} make --no-print-directory "PUBLISH=${stub}" "$@" 2>&1)" + rc=$? + calls="$(cat "${log}" 2>/dev/null || true)" +} + +pass() { passed=$((passed + 1)); echo "ok $1"; } +fail() { failed=$((failed + 1)); echo "FAIL $1"; printf ' rc=%s calls=[%s]\n' "${rc}" "${calls}"; printf '%s\n' "${out}" | sed 's/^/ | /'; } + +# ok NAME WANT [VAR=value ...] -- ARGS: make succeeds and calls the tool +# exactly once, as WANT ("args|ARGS|VERSION|TAG"). +ok() { + local name="$1" want="$2"; shift 2 + mk "$@" + if [[ "${rc}" -eq 0 && "${calls}" == "${want}" ]]; then pass "${name}"; else fail "${name} (want once: ${want})"; fi +} + +# refused NAME [VAR=value ...] -- ARGS: make fails and never calls the tool. +refused() { + local name="$1"; shift + mk "$@" + if [[ "${rc}" -ne 0 && -z "${calls}" ]]; then pass "${name}"; else fail "${name} (want failure, no call)"; fi +} + +echo "git-parallel reports ${cur}" + +ok "positional" "publish -from-make|${cur}||" -- publish "${cur}" +ok "VERSION=" "publish -from-make||${cur}|" -- publish "VERSION=${cur}" +ok "tag= (compatible)" "publish -from-make|||${cur}" -- publish "tag=${cur}" +ok "YES=1 on the command line" "publish -from-make -yes|${cur}||" -- publish "${cur}" YES=1 +ok "YES other than 1 does not confirm" "publish -from-make|${cur}||" -- publish "${cur}" YES=yes +ok "YES and VERSION from the environment are ignored" "publish -from-make|${cur}||" YES=1 VERSION=v9.9.9 -- publish "${cur}" +ok "parallel make runs the tool once" "publish -from-make|${cur}||" -- -j4 publish "${cur}" +ok "check" "check -from-make|${cur}||" -- publish-check "${cur}" +ok "check with VERSION=" "check -from-make||${cur}|" -- publish-check "VERSION=${cur}" +ok "status (any version)" "status -from-make|${other}||" -- publish-status "${other}" +ok "watch" "watch -from-make|${cur}||" -- publish-watch "${cur}" +ok "help" "help|||" -- publish-help + +# The version check runs first: a tag the release workflow would refuse +# (the script reports another version) is never pushed. +refused "publish of a version the script does not report" -- publish "${other}" +refused "publish with tag= the script does not report" -- publish "tag=${other}" +# publish-check reports the preflight too, and still fails. +mk -- publish-check "${other}" +if [[ "${rc}" -ne 0 && "${calls}" == "check -from-make|${other}||" ]]; then + pass "check of another version runs the preflight and fails" +else + fail "check of another version runs the preflight and fails" +fi + +refused "no version" -- publish +refused "check without a version" -- publish-check +refused "two versions" -- publish "${cur}" v2.0.0 +refused "a goal and VERSION=" -- publish "${cur}" "VERSION=${cur}" +refused "VERSION= and tag=" -- publish "VERSION=${cur}" "tag=${cur}" +refused "another target first" -- lint publish "${cur}" +refused "two publish targets" -- publish publish-check "${cur}" +refused "help takes no version" -- publish-help "${cur}" +refused "a target as the version" -- publish lint +refused "a word as the version" -- publish clean +refused "no leading v" -- publish "${cur#v}" +refused "leading zero" -- publish v01.0.0 +refused "build metadata" -- publish "${cur}+build.1" +refused "invalid version in check" -- publish-check v1.x + +# Shell metacharacters stay data: were the version pasted into a recipe, +# the shell would create the file. (No spaces: a space makes two goals.) +pwned="${work}/pwned" +case "${pwned}" in *[[:space:]]*) echo "TMPDIR must not contain spaces" >&2; exit 2;; esac +refused "metacharacters (;)" -- publish "v1;:>${pwned}" +refused "metacharacters (\$())" -- publish "v1.0.0\$(:>${pwned})" +refused "metacharacters in VERSION=" -- publish "VERSION=v1.0.0\`:>${pwned}\`" +# Without the version check in front, the value reaches the tool unchanged. +ok "metacharacters reach the tool as data" "status -from-make|v1;:>${pwned}||" -- publish-status "v1;:>${pwned}" +if [[ -e "${pwned}" ]]; then fail "metacharacters ran a command"; else pass "metacharacters ran no command"; fi + +mk -- -n publish "${cur}" +if [[ "${rc}" -eq 0 && -z "${calls}" && "${out}" == *"publish -from-make"* ]]; then + pass "make -n runs nothing" +else + fail "make -n runs nothing" +fi + +echo +echo "${passed} passed, ${failed} failed" +[[ "${failed}" -eq 0 ]] From d2f6b46f272ad7aca1fe12c4740bf4a00b4f21e8 Mon Sep 17 00:00:00 2001 From: Hiroshi IKEGAMI Date: Sun, 25 Oct 2026 23:34:51 +0900 Subject: [PATCH 6/6] build: pin Hephaestus v1.2.0 Moves the action and HEPHAESTUS_VERSION from the release candidate v1.2.0-rc.1 (bae0e2b, verified by this pull request's CI) to the stable v1.2.0: magicdrive/hephaestus@160990d32bbc83af702bde8a63f49ffcac32972c, the commit the v1.2.0 tag points to. The release binary, its checksum and its attestation are verified by the action for that commit. Claude-Session: https://claude.ai/code/session_01RbGbwRHvCEhsKLZfbvbbRg --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/release.yml | 10 +++++----- Makefile | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 485b517..c58606b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,19 +108,19 @@ jobs: with: persist-credentials: false - name: Check configuration - uses: magicdrive/hephaestus@bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e # v1.2.0-rc.1 + uses: magicdrive/hephaestus@160990d32bbc83af702bde8a63f49ffcac32972c # v1.2.0 with: command: check github-token: "" - name: Plan a release candidate id: plan-rc - uses: magicdrive/hephaestus@bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e # v1.2.0-rc.1 + uses: magicdrive/hephaestus@160990d32bbc83af702bde8a63f49ffcac32972c # v1.2.0 with: command: plan tag: v0.0.0-rc.1 - name: Plan a stable release id: plan-stable - uses: magicdrive/hephaestus@bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e # v1.2.0-rc.1 + uses: magicdrive/hephaestus@160990d32bbc83af702bde8a63f49ffcac32972c # v1.2.0 with: command: plan tag: v0.0.0 @@ -133,7 +133,7 @@ jobs: [[ "$RC" == true/skip && "$STABLE" == false/publish ]] - name: Snapshot (archive, checksum) id: snapshot - uses: magicdrive/hephaestus@bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e # v1.2.0-rc.1 + uses: magicdrive/hephaestus@160990d32bbc83af702bde8a63f49ffcac32972c # v1.2.0 with: command: run stages: archive,checksum diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e061d7..8d398b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,7 @@ jobs: TAG: ${{ github.ref_name }} run: scripts/check-version.sh "$TAG" - name: Archive and checksum - uses: magicdrive/hephaestus@bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e # v1.2.0-rc.1 + uses: magicdrive/hephaestus@160990d32bbc83af702bde8a63f49ffcac32972c # v1.2.0 with: command: run stages: archive,checksum @@ -84,7 +84,7 @@ jobs: # prerelease and tap for this tag, from hephaestus.yaml. Fails for a # prerelease whose plan would update the tap. id: plan - uses: magicdrive/hephaestus@bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e # v1.2.0-rc.1 + uses: magicdrive/hephaestus@160990d32bbc83af702bde8a63f49ffcac32972c # v1.2.0 with: command: plan - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 @@ -104,7 +104,7 @@ jobs: # Checks the tap token, the tap layout, a newer formula on the tap # and the pull request branch before the release becomes public, # since a published release cannot be taken back. - uses: magicdrive/hephaestus@bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e # v1.2.0-rc.1 + uses: magicdrive/hephaestus@160990d32bbc83af702bde8a63f49ffcac32972c # v1.2.0 with: command: run stages: formula,tap @@ -118,14 +118,14 @@ jobs: dist/git-parallel_*_noarch.tar.gz dist/git-parallel_*_checksums.txt - name: Publish the GitHub release - uses: magicdrive/hephaestus@bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e # v1.2.0-rc.1 + uses: magicdrive/hephaestus@160990d32bbc83af702bde8a63f49ffcac32972c # v1.2.0 with: command: release - name: Generate the formula and open a tap pull request # Prereleases (vX.Y.Z-rc.N) skip the tap (brews.skip_upload: auto), # so the plan skips this step. if: steps.plan.outputs.tap == 'publish' - uses: magicdrive/hephaestus@bae0e2b3cbf41ad27299e0cfc3517a6affc7e15e # v1.2.0-rc.1 + uses: magicdrive/hephaestus@160990d32bbc83af702bde8a63f49ffcac32972c # v1.2.0 with: command: run stages: formula,tap diff --git a/Makefile b/Makefile index 03dd752..d94581c 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ SHELL_SOURCES := git-parallel tests/run.sh tests/publish-make.sh scripts/check-v scripts/homebrew-e2e.sh # renovate: datasource=go depName=github.com/magicdrive/hephaestus -HEPHAESTUS_VERSION ?= v1.2.0-rc.1 +HEPHAESTUS_VERSION ?= v1.2.0 HEPHAESTUS ?= go run github.com/magicdrive/hephaestus@$(HEPHAESTUS_VERSION) # renovate: datasource=go depName=github.com/rhysd/actionlint ACTIONLINT_VERSION ?= v1.7.12