A private repository on vm4006 (hs.hsadmin.ng) served every artifact without the control token: the repository is `internal` in a private organisation, and Werkator asked Gitea with its token and read only the `private` flag. - Visibility is asked as a visitor (without token); 401/403/404 and a visible `private`/`internal` answer mean private. An unclear answer hides the repository (fail closed, retried after a minute). Artifact routes answer 404 instead of 500 for unknown or hidden names. - `builds.<name>.publicArtifacts` releases chosen artifacts of a private repository: globs over workspace paths like `artifactDirs`, or `true`. The paths come from the built commit's own `.werkator.yml`; only a branch the primary configuration names literally in that definition's `trigger.branches` publishes. - Released artifacts answer `server.corsAllowedOrigins` (PR#96) via a Spring `CorsFilter`, preflight included; nothing else carries a CORS header. PR-doc: `docs/prs/2026-10-04-PR#114-private-artifacts-and-public-paths.md` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: mhoennig <michael@hoennig.de> Reviewed-on: #114
18 KiB
PR#114: Private Artifacts Stay Private, Released Paths Become Public
This document describes the change of this pull request at the time it was made. Later pull requests may change the behaviour without updating it.
The Problem
On vm4006 every artifact of hs.hsadmin.ng was served without the control token: build.log, reports/**, the jars.
The forge hides the repository from visitors: https://dev.hostsharing.net/api/v1/repos/hostsharing/hs.hsadmin.ng answers 404 without a login.
PR#43 promises that every route of a private repository answers 404 without the control token.
The routes were covered.
The visibility check was wrong.
GiteaClient.readVisibility asked with the instance's token and read only the private flag.
hs.hsadmin.ng is internal (signed-in users only) in an organisation whose visibility is private.
To the token it answers private: false, so Werkator called the repository public and served everything.
Some artifacts of a private repository are meant for everyone, though.
hsadmin-ng's code city (reports/codecharta/mutation-crap.cc.json) is to be shown by the CodeCharta viewer at javagil.de, without a token, while everything else stays hidden.
Two smaller defects came to light on the way:
- A forge that did not answer kept the last answer, so a repository once seen as public stayed public through an outage.
- The artifact routes answered 500 instead of 404 for an unknown repository name and for a hidden one.
Non-Goals
- Repositories without a configured forge stay public, as PR#43 decided (Scenario#43.04): there is nothing to ask and nothing to hide behind.
- No new key for the allowed websites:
server.corsAllowedOrigins(PR#96) serves. - No switch in the host configuration: the released paths change with the code, and the branch limit is the primary configuration's trigger.
- Released builds show no page: the branch pages, the build list and the artifact index of a private repository stay hidden.
The Scenarios
Feature: a private repository serves no artifact to a visitor
Background
- A visitor is a browser without the control token cookie.
- The artifact routes are
/repos/<name>/artifacts/<key>/<path>,/repos/<name>/branches/<build-key>/<path>and their unscoped forms/artifacts/…and/branches/….
Scenario#114.01: a repository the forge hides from visitors serves no artifact
… so that a private repository gives out no content, however the forge closes it.
- Given the forge shows the repository to its token as
private: false, internal: truein a private organisation- and answers 404 to a request without a token
- When a visitor requests any artifact route of the repository, a report, the build log or a jar
- Then every one of them answers 404
- And a repository the forge shows to a visitor still serves its artifacts, as before
Verified by
- ArtifactVisibilityIntegrationTest
a repository internal in a private organisation serves no artifact to a visitor,a repository the forge shows a visitor serves its artifacts, as before - GiteaClientIntegrationTest
asks as a visitor, without the token,a repository the forge hides from a visitor is private, however it is closed,a visible answer that still says private or internal is private
Scenario#114.02: a forge that does not answer hides the repository
… so that a failed network call never reveals a private repository.
- Given the forge is configured for the repository
- When it answers with an error, an answer without the
privateflag, or not at all- or the forge is configured without owner or repository name
- Then the repository counts as private, even when it was public at the last answer
- and the forge is asked again after a minute
- And a missing token changes nothing, since the forge is asked as a visitor
Verified by
- ArtifactVisibilityIntegrationTest
a forge that does not answer serves no artifact to a visitor - RepositoryPrivacyUnitTest
a silent forge hides even what was public, and is asked again within a minute - GiteaClientIntegrationTest
a forge that cannot be asked is UNKNOWN, never public,a forge named without the repository is UNKNOWN, without a request,no forge at all is UNCONFIGURED; a missing token does not matter
Scenario#114.03: an artifact route of an unknown or hidden repository answers 404
… so that the answer is the documented one and does not tell a private repository from an invented name.
- Given a visitor
- When they request an artifact route naming a hidden repository, or a name the instance does not serve
- Then both answer 404 with the same body, the name aside
Verified by
- ArtifactVisibilityIntegrationTest
an unknown repository and a hidden one answer the same 404 on the artifact routes
Feature: a private repository releases chosen artifacts
Background
- The primary configuration is the checkout's
.werkator.ymlwith the host layers; a branch's own committed file is not part of it. - The example is hsadmin-ng's
releasedefinition, built formasterasmaster@release, permanent keymaster_release.
Scenario#114.04: a released artifact is served to everyone
… so that a viewer on another site loads the code city of a private repository without a token.
- Given the primary configuration has
builds.release.trigger.branches: ["master"]- and the
.werkator.ymlof the builtmastercommit hasbuilds.release.publicArtifacts: ["build/reports/codecharta/**"]
- and the
- When a visitor requests
/repos/hs.hsadmin.ng/branches/master_release/reports/codecharta/mutation-crap.cc.json- or the same file under the unscoped form or under the build's artifact key
- Then the file is served, sandboxed like every artifact
- And a released directory serves its page, after the redirect to the trailing slash
Verified by
- ArtifactPublicationUnitTest
the built commit's own file names the released paths of its definition - ArtifactFileControllerRestTest
a released artifact is served on every route,a released directory serves its page, after the redirect to the trailing slash
Scenario#114.05: every other path of a private repository stays hidden
… so that a release opens exactly the listed files and not the log or the jar beside them.
- Given the release of Scenario#114.04
- When a visitor requests the build log, a jar, an unlisted report, an unknown file, an unknown branch or key, or a path climbing out of a released directory
- Then each answers 404 with the body of an unknown repository on a named route
- And a browser holding the control token still gets everything
Verified by
- ArtifactPublicationUnitTest
every other path of the released build stays hidden - ArtifactFileControllerRestTest
every other path answers like an unknown repository,the control token opens everything, released or not
Scenario#114.06: a branch cannot publish its own artifacts
… so that a careless commit on a branch never publishes what it builds.
- Given a branch
featurewhose own.werkator.ymlsetsbuilds.release.trigger.branches: ["feature"]andpublicArtifacts: true- or sets
publicArtifacts: trueonbuilds.default, which the primary configuration selects for["*", "!master"]
- or sets
- When the watcher builds
feature@releaseorfeature - Then none of their artifacts is released: the primary configuration does not name
featureliterally - And a primary configuration that stops naming
masterwithdraws the release ofmaster@releaseat once
Verified by
- ArtifactPublicationUnitTest
a branch that gives itself the definition and the paths releases nothing,a definition the primary config selects by a glob releases nothing,a primary config that stops naming the branch withdraws the release at once
Scenario#114.07: the released paths change with the code
… so that what is published is reviewed and versioned like the code that writes it.
- Given
publicArtifactsistrue,false, absent, or a list of globs over workspace paths, written as inartifactDirs - When a build of a literally named branch is served
- Then the
.werkator.ymlof its commit decides:trueall, a list its matches,falseor absent nothing- and each build by its own commit, so an older build keeps the paths it was built with
- And
build/reports/codecharta/**releases what the URL shows under…/reports/codecharta/, sincebuild/reportsis stored asreports/ - And a value of another shape refuses the configuration, and a commit whose file cannot be read releases nothing
Verified by
- ArtifactPublicationUnitTest
without the key, or with false, nothing is released; true releases everything,the paths change with the code: each build is judged by its own commit,a commit's file is read once, as long as the primary config stays,a commit whose file cannot be read releases nothing of its own,the globs name workspace paths, as artifactDirs does - ConfigLoaderUnitTest
publicArtifacts reads true, false or a list of globs, and refuses anything else
Scenario#114.08: the pages of a private repository stay hidden
… so that a release reveals a file, not the branches, builds and commits around it.
- Given the release of Scenario#114.04
- When a visitor requests the artifact index
/repos/hs.hsadmin.ng/branches/master_release, the branch list or the build history - Then they answer 404, as before
Verified by
- ArtifactFileControllerRestTest
the artifact index of the released build stays hidden - BuildsApiControllerRestTest
a named private repository answers a visitor exactly like an unserved name(Scenario#54.01)
Scenario#114.09: the Forgejo export names a release
… so that a workflow converted from the file does not silently lose the publication (ADR 0010).
- Given a definition with
publicArtifacts - When it is exported as a Forgejo Actions workflow
- Then the export lists it as untranslated: a Forgejo artifact is exactly as visible as its repository
Verified by
- ForgejoWorkflowExporterUnitTest
released artifacts are named, since Forgejo shows an artifact only with its repository
Feature: a released artifact is readable from an allowed website
Background
server.corsAllowedOriginsin the instance file names the websites whose scripts may read artifacts (PR#96). This PR reuses it instead of a second list: it is instance-only already, and a released artifact is shared under the same rule as any artifact of a public repository.
Scenario#114.10: a released artifact answers an allowed origin, preflight included
… so that the CodeCharta viewer at javagil.de loads the code city of a private repository.
- Given
server.corsAllowedOrigins: ["https://javagil.de"]and the release of Scenario#114.04 - When a script at
https://javagil.defetches the released file - Then the response carries
Access-Control-Allow-Origin: https://javagil.deandVary: Origin, and noAccess-Control-Allow-Credentials - And an
OPTIONSpreflight forGETis answered with the same origin, the allowed methods and the requested headers
Verified by
- ArtifactFileControllerRestTest
a released artifact answers an allowed origin, without credentials,the preflight of a released artifact is answered
Scenario#114.11: nothing else carries a CORS header
… so that another website's script never reads what a visitor could not.
- Given the setting of Scenario#114.10
- When a script fetches an artifact of the private repository that is not released, even from a browser holding the control token
- or a released artifact from an origin the instance does not name
- Then neither the response nor its preflight carries
Access-Control-Allow-Origin - And every artifact of a public repository answers an allowed origin, as since PR#96
Verified by
- ArtifactFileControllerRestTest
an artifact that is not released gets no CORS header, not even with the token,a released artifact gets no CORS header for an origin the instance does not name,an artifact file answers an allowed origin, without credentials(Scenario#96.01),another origin, or none, gets no CORS header(Scenario#96.02)
The Solution
Asking the forge as a visitor
GiteaClient.readVisibility now asks without the token.
The question is what an anonymous visitor may see, and the forge's answer to an anonymous caller covers every way a repository can be closed: private, internal, a private or limited organisation, an instance that requires a sign-in.
401, 403 and 404 mean private.
A visible answer that still says private or internal is private too.
Everything else that is no clear answer is UNKNOWN.
The red run: ArtifactVisibilityIntegrationTest against the code before this PR fails Scenario#114.01, #114.02 and #114.03, and passes the public case.
Failing closed
RepositoryPrivacy treats UNKNOWN as private, always.
An answer that is no longer confirmed is not an answer.
The guess is kept for one minute instead of fifteen, so a public repository is back soon after the forge.
404 instead of 500
ArtifactFileController handles UnknownRepositoryException like UiController does.
Releasing artifacts: what from the commit, who from the primary configuration
builds.<name>.publicArtifacts is true or a list of java.nio globs over workspace paths (PublicArtifacts).
They are written like artifactDirs, so build/reports/codecharta/** and not the stored reports/codecharta/**; ArchivedPaths maps a stored file back to where the build wrote it, the same rule FileArtifactStore archives by.
ArtifactPublication answers whether a file of a build is released, from two sources on purpose:
- What: the
.werkator.ymlof the built commit, read withgit showand merged like the build's own configuration. The released paths are written, reviewed and versioned with the code that produces the files. A commit never changes, so the answer is cached per commit while the primary configuration stays the same. - Who: the primary configuration must name the build's branch literally in the definition's
trigger.branches. A branch writes its own file, so it could otherwise publish its own artifacts; and it may redefine a trigger for itself, which is why the primary one counts. A glob does not count: it also selects branches that do not exist yet.
Considered and dropped: reading the release from the host configuration only.
Paths belong to the code, and a switch in the instance file would be true for every repository anyway.
ArtifactFileController resolves a private repository for a visitor (RepoRouting.served) and serves a file only when its build released it — a directory by the page it serves.
Everything else answers exactly what it answered before: the 404 of an unknown repository on a named route, the route's own 404 on the unscoped form.
The check reads the configuration, so a private repository's 404 may take a few milliseconds longer than an invented name's; the forge is never asked on this path.
ADR 0010: the key shapes how Werkator serves its stored artifacts, like the retention keys it already exempts; the workflow counterpart of the files themselves is actions/upload-artifact, and the export names the release as untranslated.
CORS in one place
ArtifactCors is the CorsConfigurationSource of a Spring CorsFilter in front of the artifact routes (ArtifactCorsConfiguration).
It shares an artifact with an origin from server.corsAllowedOrigins when everyone may have it anyway: any artifact of a public repository, a released one of a private repository.
A cross-site read carries no credentials, so the token in a browser does not widen it.
Spring's filter answers the preflight by the same rule as the GET; the controller no longer sets the header itself (PR#96 did), so the decision is not made twice.
Without a configuration the filter adds nothing, and the browser refuses the empty preflight.
ArtifactRoute parses an artifact URL with the controller's own path patterns, and ArtifactFiles holds the file resolution both share, so the filter and the controller judge the same file.