- Shell 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
A crate can carry a feature that is off by default, and nothing here ever built it: the suite and clippy ran the default set, and the one container job built the one Containerfile. Code behind such a feature reached the registry having never been compiled in CI. `features` adds a second test job with that set on. The default run stays as it is, since what gets published has to keep working on its own, and running both catches a file that only compiles with the feature on as well as one that only compiles with it off. `system-packages` installs what such a build needs from apt, for a dependency that compiles C on a runner image without the tools. `container-variants` builds more images from the same source and tag, each with its own Containerfile and a suffix on both tags, so a variant publishes `<version>-git` and `latest-git` instead of overwriting the plain ones. The release and both build jobs now also wait on the feature run, so a broken feature stops the publish. Every input defaults to off, so a caller that does not set them runs exactly as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
| .forgejo/workflows | ||
| actions | ||
| scripts | ||
| .gitignore | ||
| AGENTS.md | ||
| LICENSE | ||
| README.md | ||
ci
Shared Forgejo Actions for git.attssystem.fr: how Rust crates are tested,
audited, published and containerised here, written once.
A repository needs one file and no jobs of its own:
# .github/workflows/ci.yaml
name: CI
on:
push:
branches: [main]
tags: ["*"]
pull_request:
schedule:
- cron: "17 4 * * *"
workflow_dispatch:
jobs:
rust:
uses: attssystem/ci/.forgejo/workflows/rust-all.yaml@main
with:
event-name: ${{ github.event_name }}
secrets: inherit
rust-all picks what to run from the trigger that reached it — via
event-name, since a called workflow's own github.event_name is
workflow_call, not the trigger that reached the caller:
| Trigger | Runs |
|---|---|
| push to a branch, pull request | fmt, clippy, the suite |
| push to the default branch | the same, then the release: tag from Conventional Commits, or a pull request that bumps toward one — and, having tagged, cargo publish and the container in that same run |
| push of a tag by hand | fmt, clippy, the suite, then cargo publish and the container |
| schedule, or by hand | cargo audit, and cargo upgrade, each with its own pull request |
Requirements
-
This repository stays public. Composite actions are fetched by the runner with a token scoped to the calling repository, so a private
ciwould break them for private consumers. -
CI_BOT_TOKEN, as an organisation secret oninternalandattssystem. The only secret — nothing reads a separateCI_PACKAGE_TOKENany more — so one token covers the lot. What each scope is for:Scope What needs it write:repositorythe ci/*branch pushes, the release's tag, and the pull request API —repositoryis the scope pull requests live underwrite:issueassigning those pull requests read:organizationthe membership list an organisation's assignment is built from write:packagecargo publishand the container pushread:useroptional; lets a refusal say who the forge takes the token for Scopes are a ceiling, not access: the token's user also has to be able to see the repository, through a team with write on Code, Issues and Pull Requests. A bot outside
internalis refused a private repository's pull requests however wide its scopes — the 404 below looks the same either way.
Inputs
All optional.
| Input | Default | |
|---|---|---|
event-name |
"" |
${{ github.event_name }}; without it, audit and upgrade never run |
working-directory |
. |
space separated; a repository of independent crates names each |
test |
true |
fmt, clippy and the suite, and the gate before a publish |
network-tests |
false |
also the #[ignore] tests, reporting rather than blocking |
readme-summary |
false |
keep an  badge in README.md current |
publish |
true |
cargo publish on a tag, or on the release that made one |
container |
true |
build and push the Containerfile, likewise |
audit |
true |
cargo audit on a schedule |
upgrade |
true |
cargo upgrade against Cargo.toml on a schedule |
release |
true |
tag from Conventional Commits on a push to the default branch, or a pull request that bumps toward one |
assign |
true |
assign the audit's, the upgrade's and the release's pull requests |
toolchain |
stable |
|
registry-owner |
this repository's owner | |
manifest |
Cargo.toml |
where the container tag's version is read |
containerfile |
Containerfile |
|
runner |
ubuntu-latest |
A repository with no Containerfile sets container: false; one whose source is
not yet clean sets test: false until it is.
Why the pull requests need a token of their own
The run's own token pushes a branch and is then refused the pull request API
on a private repository: Forgejo answers 404, Can't read pulls. The branch
is there, nothing opens, and the step summary carries the compare link instead.
A permissions: block in the workflow does not change that. Forgejo ignores
one and warns on every run that it did — "use Authorized Integrations to grant
capabilities to this job instead" — so what a job may do comes from the token
it is handed, or from an Authorized Integration granted in the forge's
own settings. Hence CI_BOT_TOKEN, which is also the reason a pull request it
opens runs its own CI, unlike one the run's token opened.
When something is still refused, the error names which token was used and who the forge takes it for, which is usually the whole answer: a bot that is not a member of the organisation sees none of its private repositories.
The audit
cargo audit runs on the schedule, and for anything found asks cargo for a
newer version of the named crate. If that clears the advisory, the lockfile
change is pushed to a rolling ci/cargo-audit branch and a pull request opens —
a later run force-pushes the same branch rather than opening another. What a
bump cannot fix is reported and left for a decision: a newer major, a different
crate, or an entry in .cargo/audit.toml with the reason.
The job is red whenever an advisory was found, fixed or not. The default branch still carries it until the pull request is merged.
The upgrade
cargo upgrade also runs on the schedule, against every Cargo.toml,
including across a semver-incompatible major. Anything it bumps is pushed to a
rolling ci/cargo-upgrade branch — a separate branch from the audit's, since
they can run the same night — and a pull request opens, force-pushed by later
runs rather than duplicated. Unlike the audit, an available upgrade is not a
failing job: nothing there is broken, only newer.
The release
On every push to the default branch, rust-release looks at what landed since
the last release tag — vX.Y.Z or bare X.Y.Z, whichever style the
repository's tags already use; the next one copies it, so this does not
referee a repository into a style it does not already use. A feat:, a
fix:, a perf:, or anything marked breaking (!:, or a BREAKING CHANGE:
footer) is release-worthy; anything else — chore:, docs:, and so on — is
not. If it is, every Cargo.toml in working-directory is bumped to the
version that implies (major, minor or patch — pre-1.0, a breaking change
bumps the minor instead, the digit Cargo itself already treats as the
breaking one) and pushed to a rolling ci/release branch, opening or
force-pushing the pull request that carries it, the same way the audit's and
the upgrade's do.
Merging that pull request is what tags: the next push to the default branch
finds the bump commit's chore(release): X.Y.Z subject, tags that commit and
pushes it. Squash, merge or rebase — a squash merge's trailing (#4), which
the forge appends to the pull request's title, is matched too, so how a
repository merges does not decide whether its releases tag. cargo publish
and the container then run in that same run, checking the new tag out —
because a tag a job pushes starts no run of its own. The forge accepts the tag
and nothing follows it, which is how a release came to stop dead after Tagged v1.2.2; waiting for that tag event is waiting for something the forge never
sends. A tag someone pushes by hand does reach the forge as a push, and still
runs both the ordinary way.
Unlike this repository's own scripts/release.sh, nothing moves a floating
major tag; a leaf application has no uses: ...@v1 follower to keep it for.
Every directory moves to the same version: one release, not one per crate. A
repository of crates versioned independently of each other is not what this
bumps toward — call rust-release directly with one working-directory for
that instead of enabling it through rust-all.
Nothing here runs cargo: only Cargo.toml's version and, best effort, a
crate's own entry in its Cargo.lock are ever touched. Before a repository's
first tag, cut one by hand — nothing here invents a starting point.
Assignment
All three pull requests are assigned by default: to the owner, for a
repository owned by a user, or to every member, for one owned by an
organisation. /orgs/{owner} 404ing is what tells the two apart, so no
configuration names which kind a repository is. Assignment is a follow-up call
after the pull request exists, so it also refreshes a rolling branch's
already-open pull request — useful when an organisation's membership has
changed since it was opened. assign: false turns it off.
The actions underneath
rust-all is a wrapper. The logic is in six composite actions, which a
repository with steps of its own can call directly, sharing one checkout:
steps:
- uses: actions/checkout@v4
- uses: https://git.attssystem.fr/attssystem/ci/actions/rust-test@main
- run: ./scripts/something-only-this-repo-does.sh
- uses: https://git.attssystem.fr/attssystem/ci/actions/rust-build-native@main
with:
token: ${{ secrets.CI_BOT_TOKEN }}
The URL must be absolute: a bare attssystem/ci/... resolves against the
instance's DEFAULT_ACTIONS_URL.
| Action | Inputs beyond those above |
|---|---|
rust-test |
components, test-command, args, clippy-args, lint |
rust-build-native |
token, args |
rust-build-container |
token, username, image, version, context, build-args, latest |
rust-audit |
token, fallback-token, audit-args, branch, base, assign |
rust-upgrade |
token, fallback-token, upgrade-args, branch, base, assign |
rust-release |
token, fallback-token, branch, base, assign; outputs version, the tag it pushed or empty |
Clippy runs --no-deps: this repository's source, not the crates it pulls in.
Badges
A repository that sets readme-summary: true also gets its test counts in the
README. Put the line anywhere and CI keeps it current on every push to the
default branch:

The counts go into the URL rather than being fetched from it. shields.io
cannot read a private repository, so an endpoint badge would render for
ci, fltl and rust-common and break everywhere else; baking the value into
the path is what Forgejo's own status badge does, and is why that one works.
The update is a commit marked [skip ci], so it does not start the run that
would push again.
Forgejo serves one badge per workflow file, and honours branch and event:
[](https://git.attssystem.fr/OWNER/REPO/actions)
[](https://git.attssystem.fr/OWNER/REPO/actions)
A repository has one workflow now, so an unfiltered badge reports whichever run
came last — a nightly advisory would read as a broken build. ?event= is what
keeps the two apart. A private repository's badge renders only for someone who
can already see the repository.
Pinning
@main moves. @v1 follows the major, @v1.2.0 does not move at all.
Workflows call the actions by URL and a uses: ref cannot be an expression, so
scripts/release.sh v1.2.0 rewrites those refs to the tag on a commit of its
own before tagging — a pinned caller gets a pinned workflow and action.