diff --git a/.github/actions/lint-workflows/shellcheck_actions.py b/.github/actions/lint-workflows/shellcheck_actions.py index bef8c55..0726bdc 100644 --- a/.github/actions/lint-workflows/shellcheck_actions.py +++ b/.github/actions/lint-workflows/shellcheck_actions.py @@ -3,7 +3,10 @@ actionlint can't parse composite action manifests (rhysd/actionlint#46), so their scripts are extracted and batched through one shellcheck invocation here, with `${{ }}` expressions masked the way actionlint -masks them in workflow scripts. +masks them in workflow scripts. Covers the actions under `.github/actions` +and a repository that is itself an action, i.e. has `action.yml` at its +root; a manifest without `runs.steps` (a JavaScript or Docker action) has +no scripts and is skipped. """ import json @@ -19,13 +22,17 @@ def main(): severity = os.environ.get("SEVERITY", "warning") out = Path(tempfile.mkdtemp()) scripts = [] - for manifest in sorted(Path(".github/actions").glob("*/action.y*ml")): + manifests = sorted(Path(".").glob("action.y*ml")) + sorted( + Path(".github/actions").glob("*/action.y*ml") + ) + for manifest in manifests: parsed = subprocess.run( ["yq", "-o=json", ".", manifest], check=True, text=True, stdout=subprocess.PIPE ) - for i, step in enumerate(json.loads(parsed.stdout)["runs"]["steps"]): + steps = json.loads(parsed.stdout).get("runs", {}).get("steps") or [] + for i, step in enumerate(steps): if step.get("shell") == "bash": - script = out / f"{manifest.parent.name}-{i}.sh" + script = out / f"{manifest.parent.resolve().name}-{i}.sh" script.write_text(re.sub(r"\$\{\{.*?\}\}", "EXPR", step["run"])) scripts.append(script) if scripts: diff --git a/.github/actions/rust-version-check/action.yml b/.github/actions/rust-version-check/action.yml deleted file mode 100644 index 1de4859..0000000 --- a/.github/actions/rust-version-check/action.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Rust version check -description: >- - Check whether the Rust version specified in `rust-toolchain.toml` is out of - date with the latest stable, opening an issue if so. Compares the full - `..` of `rustc --version` with `rustup check`, because - the patch version auto-updates if unspecified in `rust-toolchain.toml`. - Assumes the repo is already checked out. The calling job needs - `issues: write`. - -inputs: - token: - description: GitHub token with `issues` write access - required: false - default: ${{ github.token }} - -runs: - using: composite - steps: - # `rustc --version` below must report the `rust-toolchain.toml` version - # while `rustup check` reports the latest stable, so both must be installed - - name: Install latest stable - shell: bash - run: rustup toolchain install stable - - - name: Parse rust-toolchain.toml - shell: bash - run: echo "TOOLCHAIN_VERSION=$(rustc --version | awk '{ print $2 }')" | tee -a $GITHUB_ENV # zizmor: ignore[github-env] - - - name: Get latest stable Rust version - shell: bash - run: echo "RUST_VERSION=$(rustup check | grep stable | awk '{print $(NF-2)}')" | tee -a $GITHUB_ENV # zizmor: ignore[github-env] - - - name: Compare Rust versions - shell: bash - run: | # zizmor: ignore[github-env] - if [[ $TOOLCHAIN_VERSION < $RUST_VERSION ]]; then - echo "VERSION_MISMATCH=true" | tee -a $GITHUB_ENV - else - echo "VERSION_MISMATCH=false" | tee -a $GITHUB_ENV - fi - - - uses: $/.github/actions/create-issue - if: env.VERSION_MISMATCH == 'true' - with: - token: ${{ inputs.token }} - title: "chore: rust toolchain needs an upgrade" - labels: debt,automated-issues - body: | - The rust version specified in `rust-toolchain.toml` (${{ env.TOOLCHAIN_VERSION }}) is out of date with the latest stable (${{ env.RUST_VERSION }}). - - Check the [rust version check](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) workflow for details. - - This issue was raised by the workflow at ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/workflow. diff --git a/.github/actions/rust-version/action.yml b/.github/actions/rust-version/action.yml index 3d4e053..e9458aa 100644 --- a/.github/actions/rust-version/action.yml +++ b/.github/actions/rust-version/action.yml @@ -1,22 +1,63 @@ -# Checks if `rust-toolchain.toml` has an outdated Rust version. If so, updates to the latest Rust release for either stable or nightly -# Assumes `rust-toolchain.toml` exists at the base of the repo or an optional subdir -# Returns the outdated test result, and old and new Rust versions as output -# -# The workflow caller is then responsible for making a commit or opening a PR with `peter-evans/create-pull-request` -# Assumes the caller checks this action out in `${{ github.workspace }}/ci-workflows`, as this path is removed at the end of the action -name: Rust version check +name: Rust version update -description: Check if crate has outdated Rust version, updating `rust-toolchain.toml` to latest release if so +description: >- + Check whether `rust-toolchain.toml` pins an outdated Rust version and, if + so, rewrite its `channel` to the latest release on the same release + channel: a `nightly` pin moves to the latest nightly, `stable` or a + version number to the latest stable, and `beta` is rejected. Reports the + result and the old and new versions as outputs and, + with `pr`, opens a pull request with the change; otherwise the caller + commits it. A PR opened with the default `GITHUB_TOKEN` does not start CI + on its own; a maintainer approves the run, which is the intended review + step. The calling job needs `contents` and `pull-requests` write + permissions for `pr`. Assumes the repo is already checked out with + `rust-toolchain.toml` at its root or in `workdir`, and that `rustup` is + installed. With `update-flake`, also moves the `flake.nix` next to + `rust-toolchain.toml` to the release: the `sha256` of its fenix + `fromToolchainFile` call, which hashes the release's channel manifest, and + the `flake-inputs` in `flake.lock` via `nix flake update`. Requires Nix + 2.19 or newer installed by the caller. inputs: - toolchain: - description: "Stable or nightly. Defaults to stable" - required: false workdir: description: "Optional subdirectory" required: false + update-flake: + description: + Also update `flake.nix` for the new release, i.e. the `sha256` of + fenix's `fromToolchainFile { file; sha256; }` and the `flake-inputs` in + `flake.lock` + required: false + default: 'false' + flake-inputs: + description: + Space-separated flake inputs to update alongside the toolchain when + `update-flake` is set; each must exist in the flake + required: false + default: fenix crane + pr: + description: + Open a pull request with the update on an `update/rust-` + branch, against the checked-out branch + required: false + default: 'false' + token: + description: GitHub token with `contents` and `pull-requests` write access + required: false + default: ${{ github.token }} + labels: + description: Comma-separated labels for the pull request + required: false + default: '' + reviewers: + description: Comma-separated reviewers for the pull request + required: false + default: '' outputs: + channel: + description: "`stable` or `nightly`, the release channel of the pinned toolchain" + value: ${{ steps.channel.outputs.channel }} outdated: description: "Boolean denoting whether `rust-toolchain.toml` is outdated" value: ${{ steps.compare-versions.outputs.outdated }} @@ -26,35 +67,80 @@ outputs: new-version: description: "Latest Rust version" value: ${{ steps.latest-rust.outputs.version }} + nix-hash: + description: "New fenix `sha256`, when `flake.nix` was updated" + value: ${{ steps.nix-hash.outputs.hash }} + pr-number: + description: "Number of the pull request, when `pr` opened or updated one" + value: ${{ steps.pr.outputs.pull-request-number }} + pr-url: + description: "URL of the pull request, when `pr` opened or updated one" + value: ${{ steps.pr.outputs.pull-request-url }} runs: using: "composite" steps: + # The release channel comes from the pinned toolchain itself, so a nightly + # pin can never be rewritten to a stable version or the other way round. + - name: Detect the release channel + id: channel + shell: bash + run: | + pin=$(sed -n 's/^channel *= *"\(.*\)".*/\1/p' rust-toolchain.toml) + case "$pin" in + nightly*) channel=nightly ;; + stable | [0-9]*) channel=stable ;; + *) + echo "::error::rust-toolchain.toml pins '$pin'; only stable and nightly toolchains are updated" + exit 1 + ;; + esac + echo "channel=$channel" | tee -a "$GITHUB_OUTPUT" + working-directory: ${{ github.workspace }}/${{ inputs.workdir }} + # `rustc --version` below must report the `rust-toolchain.toml` version + # while `rustup check` reports stable's latest release, so both must be + # installed; without stable, `rustup check` has no line to parse and the + # comparison silently reports up to date. + - name: Install latest stable + if: steps.channel.outputs.channel == 'stable' + shell: bash + run: rustup toolchain install stable --profile minimal - name: Parse `rust-toolchain.toml` shell: bash id: current-rust env: - TOOLCHAIN: ${{ inputs.toolchain }} + CHANNEL: ${{ steps.channel.outputs.channel }} + # A nightly is identified by its date, which a dated pin carries itself. + # A bare `nightly` pin only offers the installed build's commit date, + # the day before the nightly cut from it, so it reads as outdated and + # gets pinned. For stable, `rustc --version` honours the + # `rust-toolchain.toml` override and prints `rustc (...)`. run: | - if [[ "$TOOLCHAIN" == "nightly" ]]; then - version=$(rustup show | grep rustc | awk -F'[()]| ' '{ print $(NF-1) }') + if [[ "$CHANNEL" == "nightly" ]]; then + version=$(sed -n 's/^channel *= *"nightly-\([0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}\).*/\1/p' rust-toolchain.toml) + [[ -n "$version" ]] || version=$(rustc --version | awk -F'[()]| ' '{ print $(NF-1) }') else - version=$(rustup show | grep rustc | awk '{ printf $2 }') + version=$(rustc --version | awk '{ print $2 }') fi - echo "version=$version" | tee -a $GITHUB_OUTPUT + echo "version=$version" | tee -a "$GITHUB_OUTPUT" working-directory: ${{ github.workspace }}/${{ inputs.workdir }} - - name: Get latest `${{ inputs.toolchain }}` Rust release + - name: Get latest ${{ steps.channel.outputs.channel }} Rust release id: latest-rust shell: bash env: - TOOLCHAIN: ${{ inputs.toolchain }} + CHANNEL: ${{ steps.channel.outputs.channel }} + # The latest nightly's date comes from its channel manifest, the same + # file the fenix hash is taken from; rustup only reports commit dates. + # For stable, the anchored pattern selects the channel's own `rustup + # check` line: a toolchain pinned to a version is listed as + # `-` and must not match. run: | - if [[ "$TOOLCHAIN" == "nightly" ]]; then - version=$(rustup check | grep "$TOOLCHAIN" | awk -F'[()]| ' '{print $(NF-1)}') + if [[ "$CHANNEL" == "nightly" ]]; then + version=$(curl -fsSL --retry 3 https://static.rust-lang.org/dist/channel-rust-nightly.toml | sed -n 's/^date = "\(.*\)"/\1/p') else - version=$(rustup check | grep stable | awk '{print $(NF-2)}') + version=$(rustup check | grep '^stable-' | awk '{print $(NF-2)}') fi - echo "version=$version" | tee -a $GITHUB_OUTPUT + echo "version=$version" | tee -a "$GITHUB_OUTPUT" - name: Compare Rust versions id: compare-versions shell: bash @@ -66,24 +152,109 @@ runs: echo "outdated=true" | tee -a $GITHUB_OUTPUT fi working-directory: ${{ github.workspace }}/${{ inputs.workdir }} - - name: Update `Cargo.toml` + - name: Update `rust-toolchain.toml` if: steps.compare-versions.outputs.outdated == 'true' + id: update-toolchain shell: bash env: - TOOLCHAIN: ${{ inputs.toolchain }} + CHANNEL: ${{ steps.channel.outputs.channel }} LATEST_VERSION: ${{ steps.latest-rust.outputs.version }} run: | - if [[ "$TOOLCHAIN" == "nightly" ]]; then - sed -i "s/channel = .*/channel = \"nightly-$LATEST_VERSION\"/" rust-toolchain.toml + if [[ "$CHANNEL" == "nightly" ]]; then + toolchain="nightly-$LATEST_VERSION" else - sed -i "s/channel = .*/channel = \"$LATEST_VERSION\"/" rust-toolchain.toml + toolchain="$LATEST_VERSION" fi + sed -i "s/channel = .*/channel = \"$toolchain\"/" rust-toolchain.toml echo "Outdated Rust, updating" cat rust-toolchain.toml + echo "toolchain=$toolchain" | tee -a "$GITHUB_OUTPUT" working-directory: ${{ github.workspace }}/${{ inputs.workdir }} - - name: Clean up + # fenix's `fromToolchainFile { file; sha256; }` fetches the release's + # channel manifest with `pkgs.fetchurl` and reads it at evaluation time, + # so `sha256` is that manifest's hash. The Rust build inputs move in the + # same PR so their updates ride on a change that CI has to validate anyway. + - name: Update `flake.nix` for the new release + if: steps.compare-versions.outputs.outdated == 'true' && inputs.update-flake == 'true' + id: nix-hash shell: bash env: - WORKSPACE: ${{ github.workspace }} + CHANNEL: ${{ steps.channel.outputs.channel }} + VERSION: ${{ steps.latest-rust.outputs.version }} + FLAKE_INPUTS: ${{ inputs.flake-inputs }} run: | - rm -rf "$WORKSPACE/ci-workflows" + if [[ "$CHANNEL" == "nightly" ]]; then + url="https://static.rust-lang.org/dist/$VERSION/channel-rust-nightly.toml" + else + url="https://static.rust-lang.org/dist/channel-rust-$VERSION.toml" + fi + hash=$(nix store prefetch-file --json "$url" | jq -r .hash) + # The range opens at the call's brace, not at any `fromToolchainFile` + # mention: lean4-nix's call on a `${system}` line would otherwise + # open and close a range before the fenix block's `sha256`. + sed -i "/fromToolchainFile *{/,/sha256 *= *\"/ s|sha256 *= *\"sha256-[^\"]*\"|sha256 = \"$hash\"|" flake.nix + if ! grep -qF "$hash" flake.nix; then + echo "::error::flake.nix has no fromToolchainFile call with a sha256 to update" + exit 1 + fi + read -ra flake_inputs <<< "$FLAKE_INPUTS" + nix flake update "${flake_inputs[@]}" --refresh + echo "hash=$hash" | tee -a "$GITHUB_OUTPUT" + working-directory: ${{ github.workspace }}/${{ inputs.workdir }} + # Only the files this action edits go into the PR, so unrelated changes in + # the caller's working tree never ride along. + - name: Describe the pull request + if: steps.compare-versions.outputs.outdated == 'true' && inputs.pr == 'true' + id: describe + shell: bash + env: + WORKDIR: ${{ inputs.workdir }} + UPDATE_FLAKE: ${{ inputs.update-flake }} + FLAKE_INPUTS: ${{ inputs.flake-inputs }} + CHANNEL: ${{ steps.channel.outputs.channel }} + OLD_VERSION: ${{ steps.current-rust.outputs.version }} + TOOLCHAIN: ${{ steps.update-toolchain.outputs.toolchain }} + NIX_HASH: ${{ steps.nix-hash.outputs.hash }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + prefix="${WORKDIR:+${WORKDIR%/}/}" + { + echo "paths<> "$GITHUB_OUTPUT" + + if [[ "$CHANNEL" == "nightly" ]]; then + release="\`$TOOLCHAIN\`" + else + release="[$TOOLCHAIN](https://github.com/rust-lang/rust/releases/tag/$TOOLCHAIN)" + fi + { + echo "body<> "$GITHUB_OUTPUT" + - name: Open a pull request + if: steps.compare-versions.outputs.outdated == 'true' && inputs.pr == 'true' + id: pr + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ inputs.token }} + add-paths: ${{ steps.describe.outputs.paths }} + branch: update/rust-${{ steps.update-toolchain.outputs.toolchain }} + delete-branch: true + commit-message: "chore: Update Rust to ${{ steps.update-toolchain.outputs.toolchain }}" + title: "chore: Update Rust to ${{ steps.update-toolchain.outputs.toolchain }}" + body: ${{ steps.describe.outputs.body }} + labels: ${{ inputs.labels }} + reviewers: ${{ inputs.reviewers }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b9da006..12ef75a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -94,9 +94,10 @@ jobs: - uses: $/.github/actions/wasm - uses: $/.github/actions/unused-deps - # An up-to-date pin must not open an issue; an outdated pin must open one - # with the parsed versions. Issue calls go to the same stub `gh` as above. - rust-version-check: + # An up-to-date pin leaves both files alone; an outdated pin moves the + # channel and the fenix hash to the latest stable. The expected hash is + # recomputed here independently of the action's own code. + rust-version: runs-on: ubuntu-latest permissions: contents: read @@ -104,35 +105,98 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Install stub gh + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + with: + github_access_token: ${{ github.token }} + # A flake in a git checkout only sees tracked files, so the fixture is + # staged for `nix flake update` to find it. + - name: Pin an up-to-date toolchain with a placeholder fenix hash run: | - mkdir -p "$RUNNER_TEMP/stub-bin" - cat > "$RUNNER_TEMP/stub-bin/gh" <<'EOF' - #!/usr/bin/env bash - echo "gh $*" >> "$GH_CALL_LOG" - case "$1 $2" in - "issue list") echo "[]" ;; - "issue create") echo "https://github.com/example/repo/issues/1" ;; - esac + printf '[toolchain]\nchannel = "stable"\n' > rust-toolchain.toml + cat > flake.nix <<'EOF' + { + inputs.fenix.url = "github:nix-community/fenix"; + inputs.crane.url = "github:ipetkov/crane"; + outputs = { fenix, ... }: { + rustToolchain = fenix.packages.x86_64-linux.fromToolchainFile { + file = ./rust-toolchain.toml; + sha256 = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + }; + }; + } EOF - chmod +x "$RUNNER_TEMP/stub-bin/gh" - echo "$RUNNER_TEMP/stub-bin" >> "$GITHUB_PATH" - echo "GH_CALL_LOG=$RUNNER_TEMP/gh-calls.log" | tee -a "$GITHUB_ENV" - - name: Pin an up-to-date toolchain - run: printf '[toolchain]\nchannel = "stable"\n' > rust-toolchain.toml - - uses: $/.github/actions/rust-version-check + git add flake.nix rust-toolchain.toml + - uses: $/.github/actions/rust-version + id: current + with: + update-flake: 'true' + - name: Assert an up-to-date pin is left alone + env: + CHANNEL: ${{ steps.current.outputs.channel }} + OUTDATED: ${{ steps.current.outputs.outdated }} + run: | + set -x + [[ "$CHANNEL" == stable ]] + [[ "$OUTDATED" != true ]] + grep -qF 'channel = "stable"' rust-toolchain.toml + grep -qF 'sha256-AAAA' flake.nix + [[ ! -e flake.lock ]] - name: Pin an outdated toolchain run: | printf '[toolchain]\nchannel = "1.70.0"\n' > rust-toolchain.toml # Install it so `rustup show` reports it as the active version rustup toolchain install 1.70.0 --profile minimal - - uses: $/.github/actions/rust-version-check - - name: Assert only the outdated pin opened an issue + - uses: $/.github/actions/rust-version + id: outdated + with: + update-flake: 'true' + - name: Assert the channel and fenix hash moved to the latest stable + env: + OUTDATED: ${{ steps.outdated.outputs.outdated }} + OLD_VERSION: ${{ steps.outdated.outputs.old-version }} + NEW_VERSION: ${{ steps.outdated.outputs.new-version }} + NIX_HASH: ${{ steps.outdated.outputs.nix-hash }} + run: | + set -x + [[ "$OUTDATED" == true ]] + [[ "$OLD_VERSION" == 1.70.0 ]] + [[ "$NEW_VERSION" == "$(rustc +stable --version | awk '{ print $2 }')" ]] + grep -qF "channel = \"$NEW_VERSION\"" rust-toolchain.toml + expected="sha256-$(curl -fsSL "https://static.rust-lang.org/dist/channel-rust-$NEW_VERSION.toml" | openssl dgst -sha256 -binary | base64 -w0)" + [[ "$NIX_HASH" == "$expected" ]] + grep -qF "sha256 = \"$expected\"" flake.nix + # `nix flake update fenix crane` locked both inputs + jq -e '.nodes.fenix.locked.rev and .nodes.crane.locked.rev' flake.lock + # A nightly pin stays on the nightly channel, moving to the latest date + - name: Pin an outdated nightly + run: printf '[toolchain]\nchannel = "nightly-2025-01-01"\n' > rust-toolchain.toml + - uses: $/.github/actions/rust-version + id: nightly + - name: Assert the channel moved to the latest nightly + env: + CHANNEL: ${{ steps.nightly.outputs.channel }} + OUTDATED: ${{ steps.nightly.outputs.outdated }} + OLD_VERSION: ${{ steps.nightly.outputs.old-version }} + NEW_VERSION: ${{ steps.nightly.outputs.new-version }} + run: | + set -x + [[ "$CHANNEL" == nightly ]] + [[ "$OUTDATED" == true ]] + [[ "$OLD_VERSION" == 2025-01-01 ]] + # rustc reports the commit date, the day before the nightly's own + # date, so the manifest is the independent source for the latter + [[ "$NEW_VERSION" == "$(curl -fsSL https://static.rust-lang.org/dist/channel-rust-nightly.toml | sed -n 's/^date = "\(.*\)"/\1/p')" ]] + grep -qF "channel = \"nightly-$NEW_VERSION\"" rust-toolchain.toml + # A beta pin is on neither supported channel and must fail before any edit + - name: Pin beta + run: printf '[toolchain]\nchannel = "beta"\n' > rust-toolchain.toml + - uses: $/.github/actions/rust-version + id: beta + continue-on-error: true + - name: Assert the beta pin was rejected untouched + env: + BETA_OUTCOME: ${{ steps.beta.outcome }} run: | - touch "$GH_CALL_LOG" - cat "$GH_CALL_LOG" set -x - [[ "$(grep -cF -- "issue create" "$GH_CALL_LOG")" == 1 ]] - grep -qF -- "issue create --repo $GITHUB_REPOSITORY --title chore: rust toolchain needs an upgrade" "$GH_CALL_LOG" - # The parsed toolchain version made it into the issue body - grep -qF -- "(1.70.0)" "$GH_CALL_LOG" + [[ "$BETA_OUTCOME" == failure ]] + grep -qF 'channel = "beta"' rust-toolchain.toml