Repository navigation
ci: stop spending a billed minute per job on one-line jobs - #11
Merged
Merged
Conversation
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>
RobFaustLZ
approved these changes
Sep 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
v1is moved. See the rollout note at the bottom.gradle-publish.yml— foldset-versionintopublishDeriving the release version had its own job, handing one string to
publishthroughneeds:. 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. Theworkflow_calloutputrelease-versionnow resolves fromjobs.publish.outputs.release_version. No caller reads that output today (checked all of them), and actionlint validates the job id in an outputvalue, so a half-finished edit fails CI rather than shipping.Trade worth naming: derivation moves from a
permissions: {}job into the one holdingpackages: 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 stepA build that fails before running tests uploads nothing, and
dorny/test-reportertreats 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-versionReplaces 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:
${{ github.ref }}and${{ github.ref_name }}directly into script bodies. That is template injection; this moves them to quoted env vars.-replace '^v',''— a regex where a literal was meant. Harmless forv, latent once the prefix is a caller-supplied input.Out-File -Appendwrites 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.3is no longer mistaken for a release — and it fails loudly on a version that is notx.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/ barev/ a prefix containing glob metacharacters. All correct.Supporting changes
ci.yml— nothing lintedactions/at all. actionlint globs workflow files, and only reads a local action's metadata when a workflow saysuses: ./..., 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 itsrun:body. Both added, plus an assertion for a missingshell:— 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 walkworkflow-templates/and feed its$default-branchplaceholders into a required check.dependabot.yml—directory: "/"never sawactions/*/action.yml. Moot today, silent the day the action pins auses:— 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
cigates were run locally against this branch and are green:actionlint,zizmorover both directories (it now reportsactions/derive-version/action.ymlin 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 removedshell:, a missingdescription:, and a shellcheck finding in the action body.The probe was run against a real workflow run via the live API:
found=truefor an artifact that exists,found=falseplus the notice for one that does not.Rollout
gradle-publish.ymlonly fires onrelease: created, so it cannot be exercised without a real publish — and a bad cut breaks publishing for every Java repo at once. Order:@v1)../script/cut-release.sh v1.1.0.derive-versioncome after the cut — opened before it, they fail action resolution.🤖 Generated with Claude Code