From 040276e40aff952e3c579861c4b453ef9ee0bc08 Mon Sep 17 00:00:00 2001 From: Gokul Date: Thu, 2 Apr 2026 16:31:58 +0530 Subject: [PATCH] ci: modernize workflows to match mdterm CI pattern (#81) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: modernize workflows to match mdterm CI pattern Replace monolithic build.yml with split ci.yml (parallel fmt, clippy, build+test jobs). Update all actions to modern versions (checkout@v4, dtolnay/rust-toolchain, rust-cache@v2). Overhaul release workflow with more build targets (musl, aarch64), simpler changelog, and crates.io publish step. * ci: fix broken cross-compilation targets and workspace publish It turns out that the release workflow had a couple of targets that were never going to work on GitHub-hosted runners. The aarch64-pc-windows-msvc target needs ARM64 MSVC build tools that simply aren't installed on windows-latest runners. And the aarch64-unknown-linux-musl target was configured with aarch64-linux-gnu-gcc as its linker — which is a *glibc* linker, not a musl one. The resulting binaries would silently be linked against glibc, completely defeating the point of a musl build. Remove both broken targets rather than papering over them with increasingly fragile cross-compilation hacks. The remaining six targets are all either native builds or well-supported cross- compilation (aarch64-linux-gnu with the correct gnu linker). While at it, fix cargo publish — a bare `cargo publish` from a workspace root doesn't know how to publish crates in dependency order. Use cargo-workspaces which actually handles this correctly. Also restore workflow_dispatch to CI so it can be triggered manually when needed. * ci: fix review issues in modernized workflows The CI and release workflows from the previous modernization had a few things that were just *not right*. The CI build job was running `cargo build --release` which is pointless in CI — we care about correctness and fast feedback, not optimized binaries. It was also missing `--workspace` on both build and test, so we were only checking whatever the root workspace defaults resolved to. Clippy had the same problem — only linting default features of default members, blissfully ignoring everything else. The release workflow had three issues: `git log HEAD` for first releases only shows a single commit instead of the full history, `--allow-dirty` on cargo publish silently masks unexpected checkout state, and the workflow_dispatch trigger got dropped so there's no way to manually re-run a failed release without pushing a new tag. Fix all of it. Add --workspace and --all-features where they belong, drop --release from CI build, fix the changelog range for first releases, remove --allow-dirty, and restore workflow_dispatch. * ci(release): harden release workflow against manual dispatch footguns The release workflow had a bare workflow_dispatch trigger with no inputs, which means manually re-running a failed release would use the *branch name* as the tag. The changelog would be wrong, the release would be named after a branch, and the publish job would cheerfully push to crates.io regardless. Not great. Three fixes: Require a tag input on workflow_dispatch so manual re-runs actually know what they're releasing. The changelog and release creation now use inputs.tag || github.ref_name so both paths resolve correctly. Guard the publish job with an if: startsWith(github.ref, 'refs/tags/v') check, because publishing to crates.io is irreversible and "oops" is not an acceptable rollback strategy. While at it, replace the cd-into-directory-and-back tar pattern with tar -C, because changing directories in a shell script and hoping you cd back correctly is the kind of thing that works right up until it doesn't. * ci: fix workflow_dispatch releasing into the void The release workflow happily accepts a manual dispatch with any tag string, then passes it to git log and softprops/action-gh-release without ever checking if the tag actually *exists* as a git ref. Confusion ensues — changelog generation silently produces garbage and the release gets created pointing at nothing useful. Add a tag validation step that fails fast with a clear error before any downstream jobs run. Since both build and release already depend on the changelog job via `needs`, this acts as a proper gate. While at it, add --all-features to the CI build and test steps so feature-gated code actually gets compiled and tested, not just linted by clippy. Having clippy check code that never gets built is the kind of false confidence that bites you on release day. * ci(release): tighten tag validation and deduplicate tag resolution The tag validation step was using `git rev-parse`, which happily accepts *any* git ref — branches, commit SHAs, you name it. So if someone created a branch called `v1.0.0` (don't ask), it would sail right through validation and produce a release pointing at a branch. Not great. Switch to `git tag -l` so we only accept actual tags. That's the whole point of a *tag* validation step. While at it, hoist the `inputs.tag || github.ref_name` expression into a workflow-level RELEASE_TAG env var instead of repeating it in four different places. Also add a comment on the publish job's `if` guard explaining that excluding manual dispatch is intentional — because some future maintainer *will* look at that and think it's a bug. * ci(release): fix three lurking bugs in release workflow The release workflow had a few issues that were just waiting to bite someone at the worst possible time: The prev_tag selection was grabbing *any* tag sorted by version, not just version tags. If someone ever pushed a non-v* tag (say, a test tag or a label), the changelog range would silently use that as the baseline. Filter for "^v" prefixed tags first. The cargo-workspaces install was unpinned, which means a breaking release of that tool would break *our* release pipeline. In a release workflow. The irony writes itself. Pin to 0.4.2. While at it, fix the .cargo/config.toml creation for aarch64-linux-gnu cross-compilation to use > instead of >> for the first line, so we don't append duplicate entries if the file somehow already exists. * ci(release): fix three review issues in release workflow The release workflow had a few things that would bite you at exactly the wrong moment: The prev_tag selection was using grep -v to exclude the current tag, then grabbing the first result from a descending version sort. Problem is, if you're doing a backport release for v1.0.1 and v2.0.0 already exists, you'd get v2.0.0 as your "previous" tag. The changelog range would then be backwards and produce garbage. Use sed to find the current tag's position in the sorted list and grab the one *after* it instead. The build job had no dependency on the changelog job, which means tag validation could fail and six runners would still happily churn away building binaries that nobody will ever use. Waste of perfectly good CI minutes. Add needs: [changelog] so builds are gated behind validation. While at it, cap the first-release changelog to 100 commits. An unbounded git log dumped into a GitHub release body is the kind of thing that works fine until your repo has a thousand commits and the API starts having opinions about payload size. * ci(release): parallelize build, validate tag format, cache cargo-workspaces Three things that should have been caught earlier: The build job had a `needs: [changelog]` dependency for absolutely no reason — it doesn't use any changelog outputs. All it did was serialize the pipeline and add ~20s of dead time before the actual builds started. The release and publish jobs already depend on both, so the ordering was always preserved where it matters. Remove it. The RELEASE_TAG env var comes from user input on workflow_dispatch, and we were feeding it straight into sed patterns and git log range expressions without validating the format first. Add a regex check for vX.Y.Z *before* any shell interpolation happens. Defense in depth — the trust boundary is already at repo write access, but let's not be sloppy about it. While at it, cache the cargo-workspaces binary in the publish job. Compiling it from source on every single release is the kind of waste that's easy to ignore until you don't. * ci: harden CI permissions and fix release tag validation The CI workflow was running with default token permissions, which is more access than a read-only lint-and-build pipeline should ever have. Add an explicit `permissions: { contents: read }` because least-privilege is not optional. The tag format regex in the release workflow was unanchored — it matched *prefixes*, so `v1.0.0garbage` sailed right through. Anchor it with `$` and add an optional pre-release suffix group so tags like `v1.0.0-beta.1` still work. Please don't ship unanchored validation regexes. While at it, replace the sed-based prev-tag lookup with `grep -F -x` for exact matching. The old sed pipeline treated dots in the tag as regex wildcards, which is the kind of thing that works fine until it doesn't. The new approach does literal matching and handles the no-previous-tag edge case explicitly. * ci(release): add --locked to builds and guard changelog tag lookup The release builds were running without --locked, which means cargo is free to re-resolve dependencies however it pleases. For a release binary, that's *not great* — you want reproducible builds from the exact Cargo.lock that was committed, not whatever cargo feels like doing today. While at it, the changelog generation was silently falling through to the "list all commits" path if the release tag wasn't found in the tag list. Now it emits a ::warning annotation so you at least know something went sideways instead of staring at a suspiciously long changelog wondering where it all came from. --- .github/workflows/build.yml | 50 ------- .github/workflows/ci.yml | 50 +++++++ .github/workflows/release.yml | 262 +++++++++++++++++++--------------- 3 files changed, 194 insertions(+), 168 deletions(-) delete mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 554a475..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Build - -on: - workflow_dispatch: - push: - branches: [main] - pull_request: - -jobs: - build: - name: Build - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - include: - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - - os: macos-latest - target: x86_64-apple-darwin - - os: windows-latest - target: x86_64-pc-windows-msvc - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Setup Rust - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - target: ${{ matrix.target }} - override: true - components: clippy, rustfmt - - - name: Check formatting - run: cargo fmt -- --check - - - name: Run clippy - run: cargo clippy -- -D warnings - - - name: Build - run: cargo build --target ${{ matrix.target }} - - - name: Check no-default-features - run: cargo check --no-default-features --workspace --target ${{ matrix.target }} - - - name: Run tests - run: cargo test --target ${{ matrix.target }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2193629 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --workspace --all-features -- -D warnings + + build: + name: Build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo build --workspace --all-features + - run: cargo check --no-default-features --workspace + - run: cargo test --workspace --all-features diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e29a525..b129cff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,148 +3,174 @@ name: Release on: push: tags: - - 'v*' + - "v*" workflow_dispatch: inputs: - version: - description: 'Version to use (e.g. v1.0.0)' + tag: + description: "Tag to release (e.g., v1.0.0)" required: true - default: 'test-release' -# Add permissions at workflow level permissions: contents: write +env: + CARGO_TERM_COLOR: always + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + jobs: - create-release: - name: Create Release + changelog: + name: Generate Changelog runs-on: ubuntu-latest - # You can also set permissions at the job level if needed - # permissions: - # contents: write outputs: - upload_url: ${{ steps.create_release.outputs.upload_url }} + changelog: ${{ steps.changelog.outputs.changelog }} steps: - - name: Checkout code - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - - name: Setup Rust - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - override: true - - - name: Install git-cliff + - name: Validate tag format run: | - cargo install git-cliff --force - - - name: Generate Changelog - run: | - # Debug: Show current state - echo "Current ref: ${{ github.ref_name }}" - echo "Input version: ${{ github.event.inputs.version }}" - echo "All tags:" - git tag --sort=-version:refname | head -10 - - # Generate changelog from the current tag to the previous version tag - CURRENT_TAG="${{ github.event.inputs.version || github.ref_name }}" - PREVIOUS_TAG=$(git tag --sort=-version:refname | grep "^v" | head -2 | tail -1) - - echo "Current tag: $CURRENT_TAG" - echo "Previous tag: $PREVIOUS_TAG" - - if [ -n "$PREVIOUS_TAG" ] && [ "$PREVIOUS_TAG" != "$CURRENT_TAG" ]; then - echo "Generating changelog for range: $PREVIOUS_TAG..$CURRENT_TAG" - git-cliff --tag "$CURRENT_TAG" "$PREVIOUS_TAG..$CURRENT_TAG" --output CHANGELOG.md - else - echo "Generating latest changelog for tag: $CURRENT_TAG" - git-cliff --tag "$CURRENT_TAG" --latest --output CHANGELOG.md + if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "::error::Tag '${RELEASE_TAG}' does not match expected format vX.Y.Z" + exit 1 fi - - echo "Generated changelog:" - cat CHANGELOG.md - - - name: Create Release - id: create_release - uses: softprops/action-gh-release@v1 - with: - name: "wrkflw ${{ github.event.inputs.version || github.ref_name }}" - body_path: CHANGELOG.md - draft: false - prerelease: false - tag_name: ${{ github.event.inputs.version || github.ref_name }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Validate tag exists + run: | + if ! git tag -l "${RELEASE_TAG}" | grep -q .; then + echo "::error::Tag ${RELEASE_TAG} does not exist as a git tag" + exit 1 + fi + - name: Generate changelog + id: changelog + run: | + # Get the previous tag + all_tags=$(git tag --sort=-v:refname | grep "^v") + if ! echo "$all_tags" | grep -F -x -q "${RELEASE_TAG}"; then + echo "::warning::Tag ${RELEASE_TAG} not found in tag list; generating changelog from all commits" + fi + prev_tag=$(echo "$all_tags" | grep -F -x -A1 "${RELEASE_TAG}" | tail -1) + [ "$prev_tag" = "${RELEASE_TAG}" ] && prev_tag="" # No previous tag found + if [ -z "$prev_tag" ]; then + # First release: use all commits + changelog=$(git log --pretty=format:"- %s (%h)" --no-merges --max-count=100) + else + changelog=$(git log "${prev_tag}..${RELEASE_TAG}" --pretty=format:"- %s (%h)" --no-merges) + fi + # Escape for GitHub Actions output + { + echo "changelog<> "$GITHUB_OUTPUT" - build-release: - name: Build Release - needs: [create-release] + build: + name: Build (${{ matrix.target }}) runs-on: ${{ matrix.os }} - # You can also set permissions at the job level if needed - # permissions: - # contents: write strategy: + fail-fast: false matrix: include: - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - artifact_name: wrkflw - asset_name: wrkflw-${{ github.event.inputs.version || github.ref_name }}-linux-x86_64 - - os: macos-latest - target: x86_64-apple-darwin - artifact_name: wrkflw - asset_name: wrkflw-${{ github.event.inputs.version || github.ref_name }}-macos-x86_64 - - os: macos-latest - target: aarch64-apple-darwin - artifact_name: wrkflw - asset_name: wrkflw-${{ github.event.inputs.version || github.ref_name }}-macos-arm64 - + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + - target: x86_64-apple-darwin + os: macos-latest + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-pc-windows-msvc + os: windows-latest steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Setup Rust - uses: actions-rs/toolchain@v1 + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable with: - profile: minimal - toolchain: stable - target: ${{ matrix.target }} - override: true - - - name: Build Release Binary - uses: actions-rs/cargo@v1 - with: - command: build - args: --release --target ${{ matrix.target }} - - - name: Compress Release Binary (Unix) + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + + - name: Install cross-compilation tools + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Install musl tools + if: matrix.target == 'x86_64-unknown-linux-musl' + run: | + sudo apt-get update + sudo apt-get install -y musl-tools + + - name: Configure linker for aarch64-linux-gnu + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + mkdir -p .cargo + echo '[target.aarch64-unknown-linux-gnu]' > .cargo/config.toml + echo 'linker = "aarch64-linux-gnu-gcc"' >> .cargo/config.toml + + - name: Build + run: cargo build --release --locked --target ${{ matrix.target }} + + - name: Package (Unix) if: runner.os != 'Windows' - run: | - mkdir -p compressed - cp target/${{ matrix.target }}/release/${{ matrix.artifact_name }} compressed/ - cd compressed - tar czvf ${{ matrix.asset_name }}.tar.gz ${{ matrix.artifact_name }} - echo "ASSET=${{ matrix.asset_name }}.tar.gz" >> $GITHUB_ENV - echo "ASSET_PATH=compressed/${{ matrix.asset_name }}.tar.gz" >> $GITHUB_ENV - - - name: Compress Release Binary (Windows) + run: tar czf wrkflw-${{ matrix.target }}.tar.gz -C target/${{ matrix.target }}/release wrkflw + + - name: Package (Windows) if: runner.os == 'Windows' - run: | - mkdir -p compressed - copy target\${{ matrix.target }}\release\${{ matrix.artifact_name }} compressed\ - cd compressed - 7z a ${{ matrix.asset_name }}.zip ${{ matrix.artifact_name }} - echo "ASSET=${{ matrix.asset_name }}.zip" >> $env:GITHUB_ENV - echo "ASSET_PATH=compressed\${{ matrix.asset_name }}.zip" >> $env:GITHUB_ENV shell: pwsh - - - name: Upload Release Asset - uses: softprops/action-gh-release@v1 + run: | + Compress-Archive -Path "target/${{ matrix.target }}/release/wrkflw.exe" -DestinationPath "wrkflw-${{ matrix.target }}.zip" + + - name: Upload artifact + uses: actions/upload-artifact@v4 with: - files: ${{ env.ASSET_PATH }} - tag_name: ${{ github.event.inputs.version || github.ref_name }} + name: wrkflw-${{ matrix.target }} + path: wrkflw-${{ matrix.target }}.* + + publish: + name: Publish to crates.io + runs-on: ubuntu-latest + needs: [build, changelog] + # Only publish on tag push — manual dispatch uses a branch ref, intentionally excluded + # because publishing to crates.io is irreversible + if: startsWith(github.ref, 'refs/tags/v') + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Cache cargo-workspaces + id: cache-cargo-ws + uses: actions/cache@v4 + with: + path: ~/.cargo/bin/cargo-workspaces + key: cargo-workspaces-0.4.2 + - name: Install cargo-workspaces + if: steps.cache-cargo-ws.outputs.cache-hit != 'true' + run: cargo install cargo-workspaces@0.4.2 + - name: Publish all crates + run: cargo workspaces publish --from-git --yes env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: [build, changelog] + steps: + - uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.RELEASE_TAG }} + name: wrkflw ${{ env.RELEASE_TAG }} + body: | + ## What's Changed + ${{ needs.changelog.outputs.changelog }} + files: artifacts/* + generate_release_notes: false