diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b522aaf..7093c94 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,7 +11,13 @@ version: 2 updates: - package-ecosystem: "github-actions" - directory: "/" + # `directory: "/"` covers .github/workflows/ plus an action.yml at the repo root, + # and nothing else -- so composite actions under actions/ were invisible to it. + # They pin action SHAs the same way the workflows do, and this file exists so + # those SHAs never drift. + directories: + - "/" + - "/actions/*" schedule: interval: "weekly" day: "monday" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6741280..c0074a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,10 @@ jobs: curl -fsSL -o zizmor.tgz "$url" echo "${ZIZMOR_SHA256} zizmor.tgz" | sha256sum -c - tar xzf zizmor.tgz - ./zizmor --format plain --min-severity medium --min-confidence low .github/workflows/ + # Scoped to these two directories, not `.`: workflow-templates/ holds the + # org template stubs, whose $default-branch placeholders are not valid + # workflows, and `ci` is a required check -- it must not go red on those. + ./zizmor --format plain --min-severity medium --min-confidence low .github/workflows/ actions/ - name: Assert every reusable workflow declares permissions run: | @@ -64,3 +67,44 @@ jobs: grep -q '^permissions:' "$f" || { echo "::error file=$f::missing top-level permissions:"; rc=1; } done exit $rc + + # actionlint globs workflow files, and only reads a local action's metadata when + # a workflow references it as `uses: ./...` -- which a reusable workflow here can + # never do, since `./` resolves against the CALLER's checkout. So nothing above + # looks at actions/*/action.yml at all. A missing `shell:` is the classic way a + # composite action breaks, and it breaks at runtime, in every consumer at once. + # + # The same gap costs the action's run: body its shellcheck, which actionlint + # would have run automatically had it been a workflow. So do both here. + - name: Lint composite actions + run: | + set -euo pipefail + shopt -s nullglob + rc=0 + for f in actions/*/action.yml; do + python3 -c ' + import sys, yaml + path = sys.argv[1] + with open(path, encoding="utf-8") as fh: + doc = yaml.safe_load(fh) + runs = doc.get("runs") or {} + if runs.get("using") != "composite": + sys.exit(f"::error file={path}::runs.using must be composite") + for key in ("name", "description"): + if not doc.get(key): + sys.exit(f"::error file={path}::missing top-level {key}:") + for i, step in enumerate(runs.get("steps") or []): + if "run" in step and not step.get("shell"): + sys.exit(f"::error file={path}::step {i} has run: but no shell:") + ' "$f" || rc=1 + + python3 -c ' + import sys, yaml + with open(sys.argv[1], encoding="utf-8") as fh: + doc = yaml.safe_load(fh) + for step in doc["runs"].get("steps") or []: + if step.get("shell") == "bash" and "run" in step: + print(step["run"]) + ' "$f" | shellcheck -s bash - || rc=1 + done + exit $rc diff --git a/.github/workflows/gradle-publish.yml b/.github/workflows/gradle-publish.yml index d238b1f..ee71e64 100644 --- a/.github/workflows/gradle-publish.yml +++ b/.github/workflows/gradle-publish.yml @@ -1,6 +1,6 @@ name: Gradle Publish -# Reusable. Called by the 9 Java repos' gradle-publish.yml stubs on `release: created`. +# Reusable. Called by the 11 Java repos' gradle-publish.yml stubs on `release: created`. # # Collapses the drift catalogued in the July 2026 audit: permissions present/absent, # `build` vs `publish` job id, 4- vs 6-space indentation, setup-java v5.4.0/v5.5.0/v5.6.0, @@ -39,17 +39,27 @@ on: outputs: release-version: description: 'Version derived from the release tag (tag minus version-prefix).' - value: ${{ jobs.set-version.outputs.release_version }} + value: ${{ jobs.publish.outputs.release_version }} permissions: {} jobs: - set-version: + publish: + name: Build and Publish Package runs-on: ubuntu-latest - permissions: {} # Only computes an output string; needs no token scopes + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: read + packages: write outputs: release_version: ${{ steps.version.outputs.release_version }} + steps: + # Folded in from what used to be its own job. Actions bills every job rounded + # up to a whole minute, so deriving the version separately cost a full + # runner-minute per release in every caller -- and put a serialization hop in + # front of the build for one parameter expansion. It runs before the checkout + # so a malformed tag fails immediately. - id: version env: PREFIX: ${{ inputs.version-prefix }} @@ -58,16 +68,6 @@ jobs: # pattern, so a prefix containing [ ? or * would strip the wrong thing. echo "release_version=${GITHUB_REF_NAME#"$PREFIX"}" >> "$GITHUB_OUTPUT" - publish: - name: Build and Publish Package - runs-on: ubuntu-latest - needs: [set-version] - timeout-minutes: ${{ inputs.timeout-minutes }} - permissions: - contents: read - packages: write - - steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -95,7 +95,7 @@ jobs: env: GITHUB_ACTOR: ${{ secrets.PACKAGES_ACTOR }} GITHUB_TOKEN: ${{ secrets.PACKAGES_TOKEN }} - RELEASE_VERSION: ${{ needs.set-version.outputs.release_version }} + RELEASE_VERSION: ${{ steps.version.outputs.release_version }} run: ./gradlew build -PprojectVersion="$RELEASE_VERSION" - name: Upload build artifacts @@ -113,5 +113,5 @@ jobs: env: GITHUB_ACTOR: ${{ github.actor }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_VERSION: ${{ needs.set-version.outputs.release_version }} + RELEASE_VERSION: ${{ steps.version.outputs.release_version }} run: ./gradlew publish -PprojectVersion="$RELEASE_VERSION" diff --git a/.github/workflows/test-report.yml b/.github/workflows/test-report.yml index d117516..20a5399 100644 --- a/.github/workflows/test-report.yml +++ b/.github/workflows/test-report.yml @@ -45,9 +45,39 @@ jobs: actions: read # download the artifact from the triggering run checks: write # publish the check run steps: + # A build that fails BEFORE running tests uploads nothing, and + # dorny/test-reporter treats an absent artifact as an error. That turns one + # honest red X on the build into two, the second saying nothing the first did + # not. Probing first means the reporter runs only when there is something to + # report. + # + # This lives here rather than in a caller's own `probe` job for the reason the + # whole file exists: as a separate job it would cost a full billed minute to + # avoid spending a different one. As a step it reuses this job's runner, costs + # nothing, and every caller gets it instead of just the one that wrote it. + - name: Look for the artifact on the triggering run + id: probe + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + REPO: ${{ github.repository }} + ARTIFACT: ${{ inputs.artifact }} + run: | + set -euo pipefail + count="$(gh api "repos/${REPO}/actions/runs/${RUN_ID}/artifacts" \ + --jq "[.artifacts[] | select(.name == \"${ARTIFACT}\")] | length")" + echo "${ARTIFACT} artifacts on run ${RUN_ID}: ${count}" + if [ "$count" -gt 0 ]; then + echo "found=true" >> "$GITHUB_OUTPUT" + else + echo "found=false" >> "$GITHUB_OUTPUT" + echo "::notice::No ${ARTIFACT} artifact on run ${RUN_ID}; the build likely failed before running tests. Skipping the report." + fi + # pull-requests: write is deliberately absent — with use-actions-summary: false # this publishes a check run and never comments on a PR. - uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0 + if: steps.probe.outputs.found == 'true' with: artifact: ${{ inputs.artifact }} name: ${{ inputs.check-name }} diff --git a/actions/derive-version/action.yml b/actions/derive-version/action.yml new file mode 100644 index 0000000..fca75ab --- /dev/null +++ b/actions/derive-version/action.yml @@ -0,0 +1,69 @@ +name: 'Derive version' +description: >- + Derive a release version from the current ref. On a tag matching + refs/tags/*, the version is the tag minus the prefix; on anything else it + is 0.0.0-, so a branch build still produces something sortable and traceable. + `numeric` drops any -suffix, for the several consumers that reject anything but a + bare x.y.z. + +inputs: + prefix: + description: 'Tag prefix stripped to produce the version. e.g. v, desktop-v' + required: false + default: 'v' + +outputs: + version: + description: 'Full version. 1.2.3 on a tag, 0.0.0-1a2b3c4 otherwise.' + value: ${{ steps.derive.outputs.version }} + numeric: + description: >- + Version with any -suffix dropped, always x.y.z. Inno Setup VersionInfoVersion, + Windows VERSIONINFO/AssemblyVersion, MSIX and tauri.conf.json all reject a + prerelease suffix. + value: ${{ steps.derive.outputs.numeric }} + +runs: + using: composite + steps: + # bash, not pwsh, even though every current caller but one is windows-latest. + # `shell: bash` there is Git Bash (--noprofile --norc -eo pipefail), where + # ${VAR:0:7} and ${VAR#"$PREFIX"} behave exactly as they do on Linux. Two reasons + # it is the better choice: + # + # - It writes LF. pwsh `Out-File -Append` writes CRLF, and at least one caller + # exact-string-compares `numeric` against a built binary's VERSIONINFO. + # - The pwsh original used `-replace '^v',''`, a REGEX. Harmless for `v` and + # `desktop-v`, but `prefix` is a caller-supplied input now, and a prefix + # carrying . + [ would silently strip the wrong thing. + - id: derive + shell: bash + env: + PREFIX: ${{ inputs.prefix }} + run: | + set -euo pipefail + + # Test the FULL ref, not GITHUB_REF_NAME. Callers run this on pushes to their + # default branch and on pull requests as well as on tags, so branch refs reach + # this code -- and a branch named v1.2.3 must not be mistaken for a release. + # + # "$PREFIX" is quoted inside ${..#..} for the same reason the regex was + # dropped: unquoted it is a glob pattern. + case "$GITHUB_REF" in + refs/tags/"$PREFIX"*) ver="${GITHUB_REF_NAME#"$PREFIX"}" ;; + *) ver="0.0.0-${GITHUB_SHA:0:7}" ;; + esac + + numeric="${ver%%-*}" + + # Deliberately fatal. A malformed version used to surface much later as a + # cryptic installer-compiler parse error, or as an invalid 4-part MSIX + # version -- minutes into a job that bills at 2x. Fail here instead. + if ! printf '%s' "$numeric" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::derived numeric version '$numeric' is not x.y.z (ref=$GITHUB_REF prefix=$PREFIX)" + exit 1 + fi + + echo "version=$ver" >> "$GITHUB_OUTPUT" + echo "numeric=$numeric" >> "$GITHUB_OUTPUT" + echo "version=$ver numeric=$numeric" diff --git a/script/cut-release.sh b/script/cut-release.sh index 9e5d33c..9999802 100755 --- a/script/cut-release.sh +++ b/script/cut-release.sh @@ -14,6 +14,18 @@ # # So promoting a commit to the ref that every consumer repo executes is an # explicit, local, human act. Which is the semantic we wanted anyway. +# +# UNDOING ONE. There is no reverse subcommand, on purpose: version tags are +# immutable and this script only ever cuts from HEAD of main. But the major alias +# is not immutable, and if a bad cut is live in 16 repos you do not want to be +# working that out from first principles. The lever is: +# +# git push origin -f v1 +# +# which the `v*` ruleset restricts to a human with bypass -- the same restriction +# that keeps Actions out of this script. Move the alias back first, then fix +# forward with a normal PR and a new patch cut. Do not delete the bad version tag; +# leaving it is what makes the history legible. set -euo pipefail