Skip to content

ci: stop spending a billed minute per job on one-line jobs - #11

Merged
RobFaustLZ merged 1 commit into
mainfrom
ci/fold-set-version-and-share-derive-version
Sep 11, 2026
Merged

RobFaustLZ merged 1 commit into
mainfrom
ci/fold-set-version-and-share-derive-version

Conversation

@FaustClaw

Copy link
Copy Markdown

Actions bills every job rounded up to a whole minute, so a job whose entire payload is one command costs a full runner-minute. This removes two of those and adds the shared piece that removes a third.

Nothing here reaches consumers until v1 is moved. See the rollout note at the bottom.


gradle-publish.yml — fold set-version into publish

Deriving the release version had its own job, handing one string to publish through needs:. That is a whole VM for one parameter expansion, billed to every caller, plus a serialization hop in front of the build. It ran 64 times in the last 30 days.

Now it is publish's first step — before the checkout, so a malformed tag fails immediately. The workflow_call output release-version now resolves from jobs.publish.outputs.release_version. No caller reads that output today (checked all of them), and actionlint validates the job id in an output value, so a half-finished edit fails CI rather than shipping.

Trade worth naming: derivation moves from a permissions: {} job into the one holding packages: write. The input is a ruleset-gated tag read through a quoted env var, so the exposure is nil — but it is a real isolation boundary, given up knowingly rather than by accident.

test-report.yml — the artifact probe becomes a step

A build that fails before running tests uploads nothing, and dorny/test-reporter treats an absent artifact as an error — turning one honest red X on the build into two, the second saying nothing the first did not.

One consumer had already worked this out and was running the probe as its own job. Sound reasoning, wrong placement: as a job it spends a 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 only the repo that wrote it.

New actions/derive-version

Replaces four copy-pasted version-derivation blocks in consumer repos.

This is not a cost saving — a composite action adds a small fetch and saves no minutes. It is worth doing for three defects in what it replaces:

  1. The blocks interpolate ${{ github.ref }} and ${{ github.ref_name }} directly into script bodies. That is template injection; this moves them to quoted env vars.
  2. They strip the tag prefix with -replace '^v','' — a regex where a literal was meant. Harmless for v, latent once the prefix is a caller-supplied input.
  3. pwsh Out-File -Append writes CRLF into $GITHUB_OUTPUT. One consumer exact-string-compares that value against a built binary's VERSIONINFO.

It also tests the full ref rather than the ref name, so a branch named v1.2.3 is no longer mistaken for a release — and it fails loudly on a version that is not x.y.z, rather than letting it surface minutes later as a cryptic installer-compiler parse error.

Exercised locally against 9 cases before committing: tag / prefixed tag / branch / PR merge ref / branch-named-like-a-tag / wrong-prefix tag / v1.2 / bare v / a prefix containing glob metacharacters. All correct.

Supporting changes

  • ci.yml — nothing linted actions/ at all. actionlint globs workflow files, and only reads a local action's metadata when a workflow says uses: ./..., which a reusable workflow here can never do (./ resolves against the caller's checkout). So the new action would have had neither its schema checked nor shellcheck run over its run: body. Both added, plus an assertion for a missing shell: — the classic composite-action break, which fails at runtime in every consumer at once. zizmor is scoped to .github/workflows/ actions/ and deliberately not ., which would walk workflow-templates/ and feed its $default-branch placeholders into a required check.
  • dependabot.yml — directory: "/" never saw actions/*/action.yml. Moot today, silent the day the action pins a uses: — which is exactly what this file exists to prevent.
  • cut-release.sh — documents how to undo a cut. It explained at length why promotion is a human act and said nothing about the reverse, which is the moment you least want to reason from first principles.
  • gradle-publish.yml's header said 9 Java repos. It is 11.

Verification

All four ci gates were run locally against this branch and are green: actionlint, zizmor over both directories (it now reports actions/derive-version/action.yml in its completed list), the permissions assertion, and the new composite-action lint.

The new lint was checked for false all-clears — it correctly fails on using: not composite, a removed shell:, a missing description:, and a shellcheck finding in the action body.

The probe was run against a real workflow run via the live API: found=true for an artifact that exists, found=false plus the notice for one that does not.

Rollout

gradle-publish.yml only fires on release: created, so it cannot be exercised without a real publish — and a bad cut breaks publishing for every Java repo at once. Order:

  1. Merge this (inert — every consumer pins @v1).
  2. Point one low-stakes consumer's stub at this merge SHA and cut a pre-release from a throwaway branch. Confirm the artifact publishes at the right version and the run shows one job, not two.
  3. Only then ./script/cut-release.sh v1.1.0.
  4. Consumer PRs adopting derive-version come after the cut — opened before it, they fail action resolution.

🤖 Generated with Claude Code

Actions bills every job rounded up to a whole minute, so a job whose entire
payload is one command costs a full runner-minute. Three of those here.

gradle-publish.yml derived the release version in its own `set-version` job and
handed it to `publish` through `needs`. That is one parameter expansion for a
whole VM, billed to every caller, plus a serialization hop in front of the
build. Folded into `publish` as its first step; the workflow_call output now
comes from `jobs.publish.outputs`. No caller reads that output today, and
actionlint validates the job id in an output value, so a half-finished edit
fails CI rather than shipping.

The trade worth naming: derivation now runs in the job holding packages: write
rather than one with permissions: {}. The input is a ruleset-gated tag read
through a quoted env var, so the exposure is nil -- but it is a real isolation
boundary, given up knowingly.

test-report.yml gains the artifact probe that one consumer had been running as
its own job. A build that fails before running tests uploads nothing, and
dorny/test-reporter treats an absent artifact as an error -- turning one honest
red X into two. As a separate job that check cost a billed minute to avoid
spending a different one. As a step here it costs nothing and every caller gets
it, instead of only the repo that wrote it.

New actions/derive-version composite action, replacing four copy-pasted version
blocks in consumer repos. Not a cost saving -- a composite action adds a small
fetch and saves no minutes. It is worth doing because the blocks it replaces
interpolate ${{ github.ref }} and ${{ github.ref_name }} straight into script
bodies, which is template injection; because they strip the tag prefix with a
regex where a literal was meant; and because pwsh Out-File writes CRLF into
$GITHUB_OUTPUT, which one caller then exact-string-compares against a built
binary's VERSIONINFO.

Nothing here reaches consumers until the major alias is moved.

Supporting changes:

- ci.yml: nothing linted actions/ at all. actionlint globs workflow files and
  only reads a local action's metadata via `uses: ./...`, which a reusable
  workflow can never do -- `./` resolves against the CALLER's checkout. So the
  new action got neither a schema check nor shellcheck over its run: body. Both
  added, plus an assertion for a missing `shell:`, which is how composite
  actions break at runtime in every consumer at once. zizmor is scoped to
  `.github/workflows/ actions/` and deliberately not `.`, which would walk
  workflow-templates/ and put its $default-branch placeholders into a required
  check.
- dependabot.yml: `directory: "/"` never saw actions/*/action.yml, so the SHAs
  pinned in there would have drifted silently -- against the stated point of
  the file.
- cut-release.sh: documents how to undo a cut. It explained at length why
  promotion is a human act and said nothing about the reverse.
- gradle-publish.yml header said 9 Java repos. It is 11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@FaustClaw
FaustClaw requested a review from a team as a code owner September 11, 2026 22:34
@RobFaustLZ
RobFaustLZ merged commit f9feaa4 into main Sep 11, 2026
1 check passed
@RobFaustLZ
RobFaustLZ deleted the ci/fold-set-version-and-share-derive-version branch September 11, 2026 23:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants