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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
46 changes: 45 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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
32 changes: 16 additions & 16 deletions .github/workflows/gradle-publish.yml
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 }}
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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"
30 changes: 30 additions & 0 deletions .github/workflows/test-report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
69 changes: 69 additions & 0 deletions actions/derive-version/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: 'Derive version'
description: >-
Derive a release version from the current ref. On a tag matching
refs/tags/<prefix>*, the version is the tag minus the prefix; on anything else it
is 0.0.0-<sha7>, 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"
12 changes: 12 additions & 0 deletions script/cut-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <known-good-sha>
#
# 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

Expand Down