fix: refuse a tag publish that would recreate the git tag
BlueGit has no route that attaches artifacts to an existing tag. The publish script no longer calls the create-release endpoint and no longer sends an overwrite flag. Tests lock that refusal. Co-authored-by: tim <tim@timfalken.com>
@@ -35,9 +35,9 @@ OBJ+MTL+textures, FBX, glTF/GLB, skeletanimatie, de 3D-attachmenteditor, paperdo
35
35
36
36
## BlueGit
37
37
38
De pipeline staat in `blue-ci-cd.yml`. Tests draaien op merge requests en op pushes naar `master`, `main` en `CI_DEFAULT_BRANCH`. Op een tag-push, na geslaagde tests: AppImage, Ubuntu-pakket, Arch-pakket, Windows-zip, daarna een controle, daarna één release-aanroep.
38
De pipeline staat in `blue-ci-cd.yml`. Tests draaien op merge requests en op pushes naar `master`, `main` en `CI_DEFAULT_BRANCH`. Op een tag-push, na geslaagde tests: AppImage, Ubuntu-pakket, Arch-pakket, Windows-zip, daarna een controle, daarna het release-script.
39
39
40
Die release-aanroep is de gedocumenteerde BlueGit-API (`POST /repos/$CI_PROJECT_PATH/releases`). Die API maakt de git-tag zelf. Bestaat de tag al, dan antwoordt de huidige BlueGit-code met HTTP 409. `replace: true` wordt op een tag-pipeline geweigerd (HTTP 403). Een tag-pipeline kan de eigen tag dus niet als release vullen. `scripts/ci/release.sh` doet de aanroep toch en faalt bij een niet-2xx-antwoord. Er is vanuit deze basis geen tag en geen release gestart.
40
Dat script publiceert niet. De publieke BlueGit-routes kunnen artifacts niet aan een bestaande tag of release hangen. `POST /api/v1/repos/{owner}/{repo}/releases` maakt de tag zelf (HTTP 409 als die er al is). `replace: true` is op een tag-pipeline HTTP 403. `scripts/ci/release.sh` doet geen van beide en stopt met exit 3 (`release-blocked: no-attach-endpoint`). De volgorde blijft een tag-push. De ontbrekende serverroute staat in `design.md`. Er is geen tag en geen release gestart.
41
41
42
42
`timfa/legacy-cycles` heeft alleen publieke releases; het pipelinebestand was niet te lezen. De gebruikte YAML komt uit de BlueGit-CI-specificatie en uit de publieke pipelines van `timfa/posprint`, `timfa/minecart-trains` en `timfa/click-tween`.
43
43
@@ -35,9 +35,9 @@ OBJ+MTL+textures, FBX, glTF/GLB, skeletanimatie, de 3D-attachmenteditor, paperdo35
35
36
## BlueGit36
## BlueGit37
37
38
De pipeline staat in `blue-ci-cd.yml`. Tests draaien op merge requests en op pushes naar `master`, `main` en `CI_DEFAULT_BRANCH`. Op een tag-push, na geslaagde tests: AppImage, Ubuntu-pakket, Arch-pakket, Windows-zip, daarna een controle, daarna één release-aanroep.38
De pipeline staat in `blue-ci-cd.yml`. Tests draaien op merge requests en op pushes naar `master`, `main` en `CI_DEFAULT_BRANCH`. Op een tag-push, na geslaagde tests: AppImage, Ubuntu-pakket, Arch-pakket, Windows-zip, daarna een controle, daarna het release-script.39
39
40
Die release-aanroep is de gedocumenteerde BlueGit-API (`POST /repos/$CI_PROJECT_PATH/releases`). Die API maakt de git-tag zelf. Bestaat de tag al, dan antwoordt de huidige BlueGit-code met HTTP 409. `replace: true` wordt op een tag-pipeline geweigerd (HTTP 403). Een tag-pipeline kan de eigen tag dus niet als release vullen. `scripts/ci/release.sh` doet de aanroep toch en faalt bij een niet-2xx-antwoord. Er is vanuit deze basis geen tag en geen release gestart.40
Dat script publiceert niet. De publieke BlueGit-routes kunnen artifacts niet aan een bestaande tag of release hangen. `POST /api/v1/repos/{owner}/{repo}/releases` maakt de tag zelf (HTTP 409 als die er al is). `replace: true` is op een tag-pipeline HTTP 403. `scripts/ci/release.sh` doet geen van beide en stopt met exit 3 (`release-blocked: no-attach-endpoint`). De volgorde blijft een tag-push. De ontbrekende serverroute staat in `design.md`. Er is geen tag en geen release gestart.41
41
42
`timfa/legacy-cycles` heeft alleen publieke releases; het pipelinebestand was niet te lezen. De gebruikte YAML komt uit de BlueGit-CI-specificatie en uit de publieke pipelines van `timfa/posprint`, `timfa/minecart-trains` en `timfa/click-tween`.42
`timfa/legacy-cycles` heeft alleen publieke releases; het pipelinebestand was niet te lezen. De gebruikte YAML komt uit de BlueGit-CI-specificatie en uit de publieke pipelines van `timfa/posprint`, `timfa/minecart-trains` en `timfa/click-tween`.43
43
@@ -10,11 +10,12 @@
10
10
# tag push, after tests succeed -> AppImage, Ubuntu package, Arch package,
11
11
# Windows build, artifact check, then one release publish
12
12
#
13
# Verified release limit: POST /repos/$CI_PROJECT_PATH/releases creates the
14
# git tag. opPublishRelease returns 409 when that tag already exists.
15
# replace=true is rejected on a tag pipeline (403, checkReplace). scripts/ci/release.sh
16
# still performs the documented call and fails on a non-2xx response. This
17
# file does not create a tag by itself. No release has been started here.
13
# Verified release limit, rechecked against api_routes.go on master: no route
14
# attaches artifacts to an existing tag or release. POST /repos/$CI_PROJECT_PATH/releases
15
# creates the git tag (409 if it exists). replace=true is 403 on a tag pipeline.
16
# scripts/ci/release.sh does not call that route and does not send replace.
17
# The release job exits 3 until BlueGit adds the attach route in design.md.
18
# This file does not create a tag. No release has been started here.
18
19
#
19
20
# posprint notes two runner facts used below: jobs start in the checkout
20
21
# (CI_PROJECT_DIR is not set), and artifact paths are exact files.
@@ -10,11 +10,12 @@10
# tag push, after tests succeed -> AppImage, Ubuntu package, Arch package,10
# tag push, after tests succeed -> AppImage, Ubuntu package, Arch package,11
# Windows build, artifact check, then one release publish11
# Windows build, artifact check, then one release publish12
#12
#13
# Verified release limit: POST /repos/$CI_PROJECT_PATH/releases creates the13
# Verified release limit, rechecked against api_routes.go on master: no route14
# git tag. opPublishRelease returns 409 when that tag already exists.14
# attaches artifacts to an existing tag or release. POST /repos/$CI_PROJECT_PATH/releases15
# replace=true is rejected on a tag pipeline (403, checkReplace). scripts/ci/release.sh15
# creates the git tag (409 if it exists). replace=true is 403 on a tag pipeline.16
# still performs the documented call and fails on a non-2xx response. This16
# scripts/ci/release.sh does not call that route and does not send replace.17
# file does not create a tag by itself. No release has been started here.17
# The release job exits 3 until BlueGit adds the attach route in design.md.18
# This file does not create a tag. No release has been started here.18
#19
#19
# posprint notes two runner facts used below: jobs start in the checkout20
# posprint notes two runner facts used below: jobs start in the checkout20
# (CI_PROJECT_DIR is not set), and artifact paths are exact files.21
# (CI_PROJECT_DIR is not set), and artifact paths are exact files.@@ -210,13 +210,30 @@ Consequences for `blue-ci-cd.yml` in this repository:
210
210
1. Syntax is limited to fields confirmed in the CI spec or in the pipelines above. No GitLab-only keyword is added (`pages`, `spec`, child pipelines, `id_tokens`).
211
211
2. Tests run for merge-request pipelines, for pushes to `master`, for pushes to `main`, for `CI_DEFAULT_BRANCH`, and for tags. This workspace's default branch is `main`. Tim's existing BlueGit projects use `master`. Covering both avoids a silent skip after the project moves.
212
212
3. On a tag push the four package jobs run only after `test`, then `verify:packages` checks magic bytes so a Linux executable is not renamed into the Windows zip. `release` needs `verify:packages`, so any failed package skips publication.
213
4. `release` calls the documented POST. The body uses `CI_COMMIT_TAG` and `CI_COMMIT_SHA`, and points each asset at the successful job and exact artifact path. **With today's API that POST returns 409**, because the tag already exists, and `replace: true` is forbidden on a tag pipeline. The job is still the requested publish step; it is not described here as a release that already succeeded. No tag is created by this setup commit.
213
4. `release` runs `scripts/ci/release.sh` after the four packages and `verify:packages`. That script does not call the create-release route and does not send `replace`. A second inspection of public BlueGit master (below) found no way to attach artifacts to the tag that already started the pipeline. The script exits 3 with `release-blocked: no-attach-endpoint`. That is a refused publish, not a created release. The tag order stays a tag push. No tag is created by this repository.
214
214
5. Package scripts map a tag `vX.Y.Z` or `X.Y.Z` to package version `X.Y.Z`. Other tag spellings fail the package job instead of inventing a version.
215
215
6. The Rust image is `rust:1.96-bookworm`. That tag is the one `timfa/posprint` already runs on BlueGit. `rust-toolchain.toml` pins the same compiler, 1.96.0, which was installed here with rustup. The machine default before that pin was 1.83.0. 1.83.0 cannot resolve the current eframe 0.29.1 graph: several transitive crates require edition 2024, which Cargo 1.83 does not parse. `bluegit/rust` is Rust 1.81 on Alpine and is the wrong image for this X11 shell. `bluegit/base` is used for the verify and release jobs, as in posprint.
216
216
7. posprint states BlueGit does not set `CI_PROJECT_DIR` and that jobs start in the checkout. Scripts use `$PWD`.
217
217
218
218
The toolchain is Rust 1.96.0, edition 2021. Edition 2021 remains explicit; the crate does not opt into edition 2024. `Cargo.lock` is committed. `cargo fmt`, `cargo check`, `cargo clippy -D warnings` and `cargo test` are the test job. No GPU test is required for the foundation shell.
219
219
220
### Release routes that exist, and the missing attach call
221
222
Rechecked against public `timfa/bluegit` master (`internal/server/api_routes.go`, `releaseRequest` in `api.go`, `opPublishRelease` in `ops_ci.go`, `checkReplace` in `release_replace.go`, `gitrepo.Tag` in `internal/gitrepo/repo.go`) and `GET /api/v1/openapi.json`. No PUT or PATCH release route exists. `releaseRequest` has `tag`, `commit`, `name`, `body`, `replace` and `assets`. There is no field that means "use the tag that is already there". `encoding/json` drops unknown fields, so a new client flag would still reach `HasTag` and return 409.
223
224
| Method | Path | What it does |
225
|---|---|---|
226
| GET | `/api/v1/projects/{owner}/{repo}/releases` | List releases. |
227
| POST | `/api/v1/projects/{owner}/{repo}/releases` | `createRelease` with a user token. `opPublishRelease` copies job artifacts, then `gitrepo.Tag` runs `git tag -a`. |
228
| GET | `/api/v1/projects/{owner}/{repo}/releases/{tag}/assets/{name}` | Download one asset. |
229
| DELETE | `/api/v1/projects/{owner}/{repo}/releases/{tag}` | Delete the release. Query `delete_tag=true` also deletes the git tag. |
230
| DELETE | `/api/v1/projects/{owner}/{repo}/releases/{tag}/assets/{name}` | Delete one asset. The tag stays. This does not upload. |
231
| POST | `/api/v1/repos/{owner}/{repo}/releases` | Same `createRelease`, and the route a job token may call. `replace: true` is allowed only by `checkReplace`: running job of this project, pipeline on an unprotected branch, not `forks/*`, actor maintainer or higher, existing release made by a job on that same branch. A tag pipeline is rejected with HTTP 403. |
232
| POST | `/api/v1/projects/{owner}/{repo}/repository/tags` | Create a git tag. No release assets. |
233
| GET | `/api/v1/projects/{owner}/{repo}/pipelines/{id}/jobs/{job}/artifacts` and `.../artifacts/{path}` | List or download a job artifact. Not a release. |
234
235
Minimal server change, not applied in this repository: add `POST /api/v1/repos/{owner}/{repo}/releases/{tag}/assets`, job token only. Body: `commit` (40 hex), optional `name` and `body`, and `assets` as `{job, path, name}` already used by `opPublishRelease`. Do not call `gitrepo.Tag` and do not accept `replace`. Return 404 when the git tag is absent. Return 409 when the tag points at a commit other than `commit`. Require a running job in this repository whose pipeline ref is `refs/tags/{tag}` and whose `CI_COMMIT_SHA` equals `commit`. Resolve each asset with `Store.ArtifactPath` from that same pipeline and copy it under `releases/{owner}/{repo}/{tag}/{name}`. If no release row exists, insert one for the existing tag. If a release row exists from a job on this same tag, replace only those named asset files after the new copies succeed, and leave the git tag and every other release alone. If the release was made by hand or from another ref, return 409 and change nothing. Reject asset names that contain `/` or `..`. Until that route exists, this project's tag job cannot publish.
236
220
237
## 16. Synthetic test bodies
221
238
222
239
`tests/fixtures/bodies/test-body-male.glb` and `test-body-female.glb` are generated by `scripts/generate_test_bodies.py`. No external model is downloaded. Both files are self-contained glTF 2.0 binary, CC0-1.0, and labelled in `asset.extras` as test assets with `finalCharacter: false`. They are not final characters.
@@ -210,13 +210,30 @@ Consequences for `blue-ci-cd.yml` in this repository:210
1. Syntax is limited to fields confirmed in the CI spec or in the pipelines above. No GitLab-only keyword is added (`pages`, `spec`, child pipelines, `id_tokens`).210
1. Syntax is limited to fields confirmed in the CI spec or in the pipelines above. No GitLab-only keyword is added (`pages`, `spec`, child pipelines, `id_tokens`).211
2. Tests run for merge-request pipelines, for pushes to `master`, for pushes to `main`, for `CI_DEFAULT_BRANCH`, and for tags. This workspace's default branch is `main`. Tim's existing BlueGit projects use `master`. Covering both avoids a silent skip after the project moves.211
2. Tests run for merge-request pipelines, for pushes to `master`, for pushes to `main`, for `CI_DEFAULT_BRANCH`, and for tags. This workspace's default branch is `main`. Tim's existing BlueGit projects use `master`. Covering both avoids a silent skip after the project moves.212
3. On a tag push the four package jobs run only after `test`, then `verify:packages` checks magic bytes so a Linux executable is not renamed into the Windows zip. `release` needs `verify:packages`, so any failed package skips publication.212
3. On a tag push the four package jobs run only after `test`, then `verify:packages` checks magic bytes so a Linux executable is not renamed into the Windows zip. `release` needs `verify:packages`, so any failed package skips publication.213
4. `release` calls the documented POST. The body uses `CI_COMMIT_TAG` and `CI_COMMIT_SHA`, and points each asset at the successful job and exact artifact path. **With today's API that POST returns 409**, because the tag already exists, and `replace: true` is forbidden on a tag pipeline. The job is still the requested publish step; it is not described here as a release that already succeeded. No tag is created by this setup commit.213
4. `release` runs `scripts/ci/release.sh` after the four packages and `verify:packages`. That script does not call the create-release route and does not send `replace`. A second inspection of public BlueGit master (below) found no way to attach artifacts to the tag that already started the pipeline. The script exits 3 with `release-blocked: no-attach-endpoint`. That is a refused publish, not a created release. The tag order stays a tag push. No tag is created by this repository.214
5. Package scripts map a tag `vX.Y.Z` or `X.Y.Z` to package version `X.Y.Z`. Other tag spellings fail the package job instead of inventing a version.214
5. Package scripts map a tag `vX.Y.Z` or `X.Y.Z` to package version `X.Y.Z`. Other tag spellings fail the package job instead of inventing a version.215
6. The Rust image is `rust:1.96-bookworm`. That tag is the one `timfa/posprint` already runs on BlueGit. `rust-toolchain.toml` pins the same compiler, 1.96.0, which was installed here with rustup. The machine default before that pin was 1.83.0. 1.83.0 cannot resolve the current eframe 0.29.1 graph: several transitive crates require edition 2024, which Cargo 1.83 does not parse. `bluegit/rust` is Rust 1.81 on Alpine and is the wrong image for this X11 shell. `bluegit/base` is used for the verify and release jobs, as in posprint.215
6. The Rust image is `rust:1.96-bookworm`. That tag is the one `timfa/posprint` already runs on BlueGit. `rust-toolchain.toml` pins the same compiler, 1.96.0, which was installed here with rustup. The machine default before that pin was 1.83.0. 1.83.0 cannot resolve the current eframe 0.29.1 graph: several transitive crates require edition 2024, which Cargo 1.83 does not parse. `bluegit/rust` is Rust 1.81 on Alpine and is the wrong image for this X11 shell. `bluegit/base` is used for the verify and release jobs, as in posprint.216
7. posprint states BlueGit does not set `CI_PROJECT_DIR` and that jobs start in the checkout. Scripts use `$PWD`.216
7. posprint states BlueGit does not set `CI_PROJECT_DIR` and that jobs start in the checkout. Scripts use `$PWD`.217
217
218
The toolchain is Rust 1.96.0, edition 2021. Edition 2021 remains explicit; the crate does not opt into edition 2024. `Cargo.lock` is committed. `cargo fmt`, `cargo check`, `cargo clippy -D warnings` and `cargo test` are the test job. No GPU test is required for the foundation shell.218
The toolchain is Rust 1.96.0, edition 2021. Edition 2021 remains explicit; the crate does not opt into edition 2024. `Cargo.lock` is committed. `cargo fmt`, `cargo check`, `cargo clippy -D warnings` and `cargo test` are the test job. No GPU test is required for the foundation shell.219
219
220
### Release routes that exist, and the missing attach call221
222
Rechecked against public `timfa/bluegit` master (`internal/server/api_routes.go`, `releaseRequest` in `api.go`, `opPublishRelease` in `ops_ci.go`, `checkReplace` in `release_replace.go`, `gitrepo.Tag` in `internal/gitrepo/repo.go`) and `GET /api/v1/openapi.json`. No PUT or PATCH release route exists. `releaseRequest` has `tag`, `commit`, `name`, `body`, `replace` and `assets`. There is no field that means "use the tag that is already there". `encoding/json` drops unknown fields, so a new client flag would still reach `HasTag` and return 409.223
224
| Method | Path | What it does |225
|---|---|---|226
| GET | `/api/v1/projects/{owner}/{repo}/releases` | List releases. |227
| POST | `/api/v1/projects/{owner}/{repo}/releases` | `createRelease` with a user token. `opPublishRelease` copies job artifacts, then `gitrepo.Tag` runs `git tag -a`. |228
| GET | `/api/v1/projects/{owner}/{repo}/releases/{tag}/assets/{name}` | Download one asset. |229
| DELETE | `/api/v1/projects/{owner}/{repo}/releases/{tag}` | Delete the release. Query `delete_tag=true` also deletes the git tag. |230
| DELETE | `/api/v1/projects/{owner}/{repo}/releases/{tag}/assets/{name}` | Delete one asset. The tag stays. This does not upload. |231
| POST | `/api/v1/repos/{owner}/{repo}/releases` | Same `createRelease`, and the route a job token may call. `replace: true` is allowed only by `checkReplace`: running job of this project, pipeline on an unprotected branch, not `forks/*`, actor maintainer or higher, existing release made by a job on that same branch. A tag pipeline is rejected with HTTP 403. |232
| POST | `/api/v1/projects/{owner}/{repo}/repository/tags` | Create a git tag. No release assets. |233
| GET | `/api/v1/projects/{owner}/{repo}/pipelines/{id}/jobs/{job}/artifacts` and `.../artifacts/{path}` | List or download a job artifact. Not a release. |234
235
Minimal server change, not applied in this repository: add `POST /api/v1/repos/{owner}/{repo}/releases/{tag}/assets`, job token only. Body: `commit` (40 hex), optional `name` and `body`, and `assets` as `{job, path, name}` already used by `opPublishRelease`. Do not call `gitrepo.Tag` and do not accept `replace`. Return 404 when the git tag is absent. Return 409 when the tag points at a commit other than `commit`. Require a running job in this repository whose pipeline ref is `refs/tags/{tag}` and whose `CI_COMMIT_SHA` equals `commit`. Resolve each asset with `Store.ArtifactPath` from that same pipeline and copy it under `releases/{owner}/{repo}/{tag}/{name}`. If no release row exists, insert one for the existing tag. If a release row exists from a job on this same tag, replace only those named asset files after the new copies succeed, and leave the git tag and every other release alone. If the release was made by hand or from another ref, return 409 and change nothing. Reject asset names that contain `/` or `..`. Until that route exists, this project's tag job cannot publish.236
220
## 16. Synthetic test bodies237
## 16. Synthetic test bodies221
238
222
`tests/fixtures/bodies/test-body-male.glb` and `test-body-female.glb` are generated by `scripts/generate_test_bodies.py`. No external model is downloaded. Both files are self-contained glTF 2.0 binary, CC0-1.0, and labelled in `asset.extras` as test assets with `finalCharacter: false`. They are not final characters.239
`tests/fixtures/bodies/test-body-male.glb` and `test-body-female.glb` are generated by `scripts/generate_test_bodies.py`. No external model is downloaded. Both files are self-contained glTF 2.0 binary, CC0-1.0, and labelled in `asset.extras` as test assets with `finalCharacter: false`. They are not final characters.@@ -1,19 +1,17 @@
1
1
#!/usr/bin/env bash
2
# Publish a BlueGit release for the tag that started this pipeline.
2
# Tag-pipeline publish step.
3
3
#
4
# Syntax checked against docs/CI.md and timfa/posprint scripts/ci/release.sh:
5
# POST "$CI_API_URL/repos/$CI_PROJECT_PATH/releases"
6
# Authorization: Bearer $CI_JOB_TOKEN
7
# assets: {job, path, name} where path is an artifact of a successful job
8
# in this pipeline.
4
# Inspected BlueGit master on 6 November 2026 (api_routes.go, ops_ci.go
5
# opPublishRelease, release_replace.go checkReplace, gitrepo.Tag):
6
# there is no route that attaches artifacts to an existing git tag or an
7
# existing release. POST /api/v1/repos/{owner}/{repo}/releases always creates
8
# the annotated tag. An existing tag returns HTTP 409 unless replace=true,
9
# and replace=true is HTTP 403 on a tag pipeline.
9
10
#
10
# Verified limit (timfa/bluegit internal/server/ops_ci.go opPublishRelease and
11
# release_replace.go checkReplace): if the tag already exists the API returns
12
# HTTP 409 unless replace=true, and replace=true is rejected with HTTP 403 on
13
# a tag pipeline. A pipeline started by pushing the tag therefore cannot
14
# attach these artifacts to that same tag. The script still performs the
15
# documented call and fails when the response is not 2xx. It does not send
16
# replace=true, and it does not invent a second tag.
11
# This script therefore does not call that endpoint and does not send
12
# replace. It exits 3 until BlueGit grows an attach route. It does not
13
# invent a second tag and it does not switch the pipeline onto a branch
14
# release. The required server change is recorded in design.md section 15.
17
15
set -euo pipefail
18
16
19
17
: "${CI_API_URL:?}" "${CI_JOB_TOKEN:?}" "${CI_COMMIT_SHA:?}" "${CI_PROJECT_PATH:?}" "${CI_COMMIT_TAG:?}"
@@ -22,34 +20,9 @@ root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
22
20
cd "$root"
23
21
version="$(bash scripts/ci/package-version.sh)"
24
22
25
echo "BlueGit release API creates the git tag." >&2
26
echo "This pipeline already runs on tag ${CI_COMMIT_TAG}, so today's API returns 409 (tag exists)." >&2
27
echo "replace=true is refused on a tag pipeline (403). No second tag is created." >&2
28
29
asset() {
30
printf '{"job":"%s","path":"%s","name":"%s"}' "$1" "$2" "$3"
31
}
32
33
assets="$(asset package:appimage dist/paperdoll-renderer-x86_64.AppImage "paperdoll-renderer-${version}-x86_64.AppImage")"
34
assets="${assets},$(asset package:ubuntu dist/paperdoll-renderer_amd64.deb "paperdoll-renderer_${version}_amd64.deb")"
35
assets="${assets},$(asset package:arch dist/paperdoll-renderer-x86_64.pkg.tar.zst "paperdoll-renderer-${version}-1-x86_64.pkg.tar.zst")"
36
assets="${assets},$(asset package:windows dist/paperdoll-renderer-windows-x86_64.zip "paperdoll-renderer-${version}-windows-x86_64.zip")"
37
assets="${assets},$(asset verify:packages dist/SHA256SUMS "paperdoll-renderer-${version}-SHA256SUMS")"
38
39
body="Paperdoll Renderer ${version}.\\n\\nFoundation shell only. Import, skeletal animation, paperdoll rendering and the 2D preview are not in this build.\\n\\n* Linux AppImage: paperdoll-renderer-${version}-x86_64.AppImage\\n* Debian/Ubuntu: paperdoll-renderer_${version}_amd64.deb\\n* Arch: paperdoll-renderer-${version}-1-x86_64.pkg.tar.zst\\n* Windows zip: paperdoll-renderer-${version}-windows-x86_64.zip\\n* SHA256SUMS"
40
41
payload="$(printf '{"tag":"%s","commit":"%s","name":"Paperdoll Renderer %s","body":"%s","assets":[%s]}' \
42
"$CI_COMMIT_TAG" "$CI_COMMIT_SHA" "$version" "$body" "$assets")"
43
44
response="$(mktemp)"
45
code="$(curl -sS -o "$response" -w '%{http_code}' -X POST "$CI_API_URL/repos/$CI_PROJECT_PATH/releases" \
46
-H "Authorization: Bearer $CI_JOB_TOKEN" \
47
-H "Content-Type: application/json" \
48
-d "$payload")" || code=000
49
cat "$response"
50
echo
51
if [[ "$code" != 2* ]]; then
52
echo "Release API returned HTTP ${code}. A 409 on this tag pipeline matches the verified BlueGit limit and is not a published release." >&2
53
exit 1
54
fi
55
echo "release published for ${CI_COMMIT_TAG}"
23
echo "release-blocked: no-attach-endpoint" >&2
24
echo "tag ${CI_COMMIT_TAG} version ${version} commit ${CI_COMMIT_SHA} project ${CI_PROJECT_PATH}" >&2
25
echo "BlueGit has no request that publishes these package artifacts onto the tag that already started this pipeline." >&2
26
echo "The create-release route would make a new annotated tag. This script does not call it." >&2
27
echo "The overwrite flag is not sent. No second tag is created." >&2
28
exit 3
@@ -1,19 +1,17 @@1
#!/usr/bin/env bash1
#!/usr/bin/env bash2
# Publish a BlueGit release for the tag that started this pipeline.2
# Tag-pipeline publish step.3
#3
#4
# Syntax checked against docs/CI.md and timfa/posprint scripts/ci/release.sh:4
# Inspected BlueGit master on 6 November 2026 (api_routes.go, ops_ci.go5
# POST "$CI_API_URL/repos/$CI_PROJECT_PATH/releases"5
# opPublishRelease, release_replace.go checkReplace, gitrepo.Tag):6
# Authorization: Bearer $CI_JOB_TOKEN6
# there is no route that attaches artifacts to an existing git tag or an7
# assets: {job, path, name} where path is an artifact of a successful job7
# existing release. POST /api/v1/repos/{owner}/{repo}/releases always creates8
# in this pipeline.8
# the annotated tag. An existing tag returns HTTP 409 unless replace=true,9
# and replace=true is HTTP 403 on a tag pipeline.9
#10
#10
# Verified limit (timfa/bluegit internal/server/ops_ci.go opPublishRelease and11
# This script therefore does not call that endpoint and does not send11
# release_replace.go checkReplace): if the tag already exists the API returns12
# replace. It exits 3 until BlueGit grows an attach route. It does not12
# HTTP 409 unless replace=true, and replace=true is rejected with HTTP 403 on13
# invent a second tag and it does not switch the pipeline onto a branch13
# a tag pipeline. A pipeline started by pushing the tag therefore cannot14
# release. The required server change is recorded in design.md section 15.14
# attach these artifacts to that same tag. The script still performs the15
# documented call and fails when the response is not 2xx. It does not send16
# replace=true, and it does not invent a second tag.17
set -euo pipefail15
set -euo pipefail18
16
19
: "${CI_API_URL:?}" "${CI_JOB_TOKEN:?}" "${CI_COMMIT_SHA:?}" "${CI_PROJECT_PATH:?}" "${CI_COMMIT_TAG:?}"17
: "${CI_API_URL:?}" "${CI_JOB_TOKEN:?}" "${CI_COMMIT_SHA:?}" "${CI_PROJECT_PATH:?}" "${CI_COMMIT_TAG:?}"@@ -22,34 +20,9 @@ root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"22
cd "$root"20
cd "$root"23
version="$(bash scripts/ci/package-version.sh)"21
version="$(bash scripts/ci/package-version.sh)"24
22
25
echo "BlueGit release API creates the git tag." >&223
echo "release-blocked: no-attach-endpoint" >&226
echo "This pipeline already runs on tag ${CI_COMMIT_TAG}, so today's API returns 409 (tag exists)." >&224
echo "tag ${CI_COMMIT_TAG} version ${version} commit ${CI_COMMIT_SHA} project ${CI_PROJECT_PATH}" >&227
echo "replace=true is refused on a tag pipeline (403). No second tag is created." >&225
echo "BlueGit has no request that publishes these package artifacts onto the tag that already started this pipeline." >&228
26
echo "The create-release route would make a new annotated tag. This script does not call it." >&229
asset() {27
echo "The overwrite flag is not sent. No second tag is created." >&230
printf '{"job":"%s","path":"%s","name":"%s"}' "$1" "$2" "$3"28
exit 331
}32
33
assets="$(asset package:appimage dist/paperdoll-renderer-x86_64.AppImage "paperdoll-renderer-${version}-x86_64.AppImage")"34
assets="${assets},$(asset package:ubuntu dist/paperdoll-renderer_amd64.deb "paperdoll-renderer_${version}_amd64.deb")"35
assets="${assets},$(asset package:arch dist/paperdoll-renderer-x86_64.pkg.tar.zst "paperdoll-renderer-${version}-1-x86_64.pkg.tar.zst")"36
assets="${assets},$(asset package:windows dist/paperdoll-renderer-windows-x86_64.zip "paperdoll-renderer-${version}-windows-x86_64.zip")"37
assets="${assets},$(asset verify:packages dist/SHA256SUMS "paperdoll-renderer-${version}-SHA256SUMS")"38
39
body="Paperdoll Renderer ${version}.\\n\\nFoundation shell only. Import, skeletal animation, paperdoll rendering and the 2D preview are not in this build.\\n\\n* Linux AppImage: paperdoll-renderer-${version}-x86_64.AppImage\\n* Debian/Ubuntu: paperdoll-renderer_${version}_amd64.deb\\n* Arch: paperdoll-renderer-${version}-1-x86_64.pkg.tar.zst\\n* Windows zip: paperdoll-renderer-${version}-windows-x86_64.zip\\n* SHA256SUMS"40
41
payload="$(printf '{"tag":"%s","commit":"%s","name":"Paperdoll Renderer %s","body":"%s","assets":[%s]}' \42
"$CI_COMMIT_TAG" "$CI_COMMIT_SHA" "$version" "$body" "$assets")"43
44
response="$(mktemp)"45
code="$(curl -sS -o "$response" -w '%{http_code}' -X POST "$CI_API_URL/repos/$CI_PROJECT_PATH/releases" \46
-H "Authorization: Bearer $CI_JOB_TOKEN" \47
-H "Content-Type: application/json" \48
-d "$payload")" || code=00049
cat "$response"50
echo51
if [[ "$code" != 2* ]]; then52
echo "Release API returned HTTP ${code}. A 409 on this tag pipeline matches the verified BlueGit limit and is not a published release." >&253
exit 154
fi55
echo "release published for ${CI_COMMIT_TAG}"@@ -0,0 +1,119 @@
1
//! The tag publish step must not recreate the git tag and must not send
2
//! replace. BlueGit has no attach-to-existing-tag route; the script refuses
3
//! the create-release call instead of issuing it.
4
5
use std::fs;
6
use std::os::unix::fs::PermissionsExt;
7
use std::path::PathBuf;
8
use std::process::Command;
9
10
fn repo_root() -> PathBuf {
11
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
12
}
13
14
fn path_prefix_env(prefix: &std::path::Path) -> std::ffi::OsString {
15
let mut path = std::ffi::OsString::from(prefix);
16
path.push(":");
17
path.push(std::env::var_os("PATH").unwrap_or_default());
18
path
19
}
20
21
fn install_curl_trap(dir: &std::path::Path) -> PathBuf {
22
let curl = dir.join("curl");
23
fs::write(
24
&curl,
25
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"${CURL_TRAP_LOG:?}\"\nexit 0\n",
26
)
27
.unwrap();
28
let mut permissions = fs::metadata(&curl).unwrap().permissions();
29
permissions.set_mode(0o755);
30
fs::set_permissions(&curl, permissions).unwrap();
31
curl
32
}
33
34
fn executable_lines(script: &str) -> String {
35
script
36
.lines()
37
.map(str::trim)
38
.filter(|line| !line.is_empty() && !line.starts_with('#'))
39
.collect::<Vec<_>>()
40
.join("\n")
41
}
42
43
#[test]
44
fn tag_publish_does_not_call_create_release_or_send_replace() {
45
let dir = std::env::temp_dir().join(format!("paperdoll-release-trap-{}", std::process::id()));
46
let _ = fs::remove_dir_all(&dir);
47
fs::create_dir_all(&dir).unwrap();
48
let log = dir.join("curl.log");
49
install_curl_trap(&dir);
50
51
let output = Command::new("bash")
52
.arg(repo_root().join("scripts/ci/release.sh"))
53
.env("PATH", path_prefix_env(&dir))
54
.env("CURL_TRAP_LOG", &log)
55
.env("CI_API_URL", "https://git.example.invalid/api/v1")
56
.env("CI_JOB_TOKEN", "bgj_test_token_not_secret")
57
.env("CI_COMMIT_SHA", "0123456789abcdef0123456789abcdef01234567")
58
.env("CI_PROJECT_PATH", "golden-age/paperdoll-renderer")
59
.env("CI_COMMIT_TAG", "v0.1.0")
60
.env_remove("PACKAGE_VERSION")
61
.output()
62
.unwrap();
63
64
let stderr = String::from_utf8_lossy(&output.stderr);
65
let stdout = String::from_utf8_lossy(&output.stdout);
66
assert_eq!(
67
output.status.code(),
68
Some(3),
69
"stdout:\n{stdout}\nstderr:\n{stderr}"
70
);
71
assert!(
72
stderr.contains("release-blocked: no-attach-endpoint"),
73
"{stderr}"
74
);
75
assert!(
76
!log.exists(),
77
"curl was invoked:\n{}",
78
fs::read_to_string(&log).unwrap_or_default()
79
);
80
let script = fs::read_to_string(repo_root().join("scripts/ci/release.sh")).unwrap();
81
let executable = executable_lines(&script);
82
assert!(
83
!executable.contains("curl"),
84
"executable release script must not call curl:\n{executable}"
85
);
86
assert!(
87
!executable.contains("replace"),
88
"executable release script must not send replace:\n{executable}"
89
);
90
let _ = fs::remove_dir_all(&dir);
91
}
92
93
#[test]
94
fn tag_publish_still_rejects_a_non_semver_tag_before_the_api_block() {
95
let dir =
96
std::env::temp_dir().join(format!("paperdoll-release-trap-bad-{}", std::process::id()));
97
let _ = fs::remove_dir_all(&dir);
98
fs::create_dir_all(&dir).unwrap();
99
let log = dir.join("curl.log");
100
install_curl_trap(&dir);
101
let output = Command::new("bash")
102
.arg(repo_root().join("scripts/ci/release.sh"))
103
.env("PATH", path_prefix_env(&dir))
104
.env("CURL_TRAP_LOG", &log)
105
.env("CI_API_URL", "https://git.example.invalid/api/v1")
106
.env("CI_JOB_TOKEN", "bgj_test_token_not_secret")
107
.env("CI_COMMIT_SHA", "0123456789abcdef0123456789abcdef01234567")
108
.env("CI_PROJECT_PATH", "golden-age/paperdoll-renderer")
109
.env("CI_COMMIT_TAG", "not-a-version")
110
.env_remove("PACKAGE_VERSION")
111
.output()
112
.unwrap();
113
assert_ne!(output.status.code(), Some(0));
114
assert_ne!(output.status.code(), Some(3));
115
let stderr = String::from_utf8_lossy(&output.stderr);
116
assert!(stderr.contains("package version must be X.Y.Z"), "{stderr}");
117
assert!(!log.exists(), "curl was invoked for an invalid tag");
118
let _ = fs::remove_dir_all(&dir);
119
}
@@ -0,0 +1,119 @@1
//! The tag publish step must not recreate the git tag and must not send2
//! replace. BlueGit has no attach-to-existing-tag route; the script refuses3
//! the create-release call instead of issuing it.4
5
use std::fs;6
use std::os::unix::fs::PermissionsExt;7
use std::path::PathBuf;8
use std::process::Command;9
10
fn repo_root() -> PathBuf {11
PathBuf::from(env!("CARGO_MANIFEST_DIR"))12
}13
14
fn path_prefix_env(prefix: &std::path::Path) -> std::ffi::OsString {15
let mut path = std::ffi::OsString::from(prefix);16
path.push(":");17
path.push(std::env::var_os("PATH").unwrap_or_default());18
path19
}20
21
fn install_curl_trap(dir: &std::path::Path) -> PathBuf {22
let curl = dir.join("curl");23
fs::write(24
&curl,25
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"${CURL_TRAP_LOG:?}\"\nexit 0\n",26
)27
.unwrap();28
let mut permissions = fs::metadata(&curl).unwrap().permissions();29
permissions.set_mode(0o755);30
fs::set_permissions(&curl, permissions).unwrap();31
curl32
}33
34
fn executable_lines(script: &str) -> String {35
script36
.lines()37
.map(str::trim)38
.filter(|line| !line.is_empty() && !line.starts_with('#'))39
.collect::<Vec<_>>()40
.join("\n")41
}42
43
#[test]44
fn tag_publish_does_not_call_create_release_or_send_replace() {45
let dir = std::env::temp_dir().join(format!("paperdoll-release-trap-{}", std::process::id()));46
let _ = fs::remove_dir_all(&dir);47
fs::create_dir_all(&dir).unwrap();48
let log = dir.join("curl.log");49
install_curl_trap(&dir);50
51
let output = Command::new("bash")52
.arg(repo_root().join("scripts/ci/release.sh"))53
.env("PATH", path_prefix_env(&dir))54
.env("CURL_TRAP_LOG", &log)55
.env("CI_API_URL", "https://git.example.invalid/api/v1")56
.env("CI_JOB_TOKEN", "bgj_test_token_not_secret")57
.env("CI_COMMIT_SHA", "0123456789abcdef0123456789abcdef01234567")58
.env("CI_PROJECT_PATH", "golden-age/paperdoll-renderer")59
.env("CI_COMMIT_TAG", "v0.1.0")60
.env_remove("PACKAGE_VERSION")61
.output()62
.unwrap();63
64
let stderr = String::from_utf8_lossy(&output.stderr);65
let stdout = String::from_utf8_lossy(&output.stdout);66
assert_eq!(67
output.status.code(),68
Some(3),69
"stdout:\n{stdout}\nstderr:\n{stderr}"70
);71
assert!(72
stderr.contains("release-blocked: no-attach-endpoint"),73
"{stderr}"74
);75
assert!(76
!log.exists(),77
"curl was invoked:\n{}",78
fs::read_to_string(&log).unwrap_or_default()79
);80
let script = fs::read_to_string(repo_root().join("scripts/ci/release.sh")).unwrap();81
let executable = executable_lines(&script);82
assert!(83
!executable.contains("curl"),84
"executable release script must not call curl:\n{executable}"85
);86
assert!(87
!executable.contains("replace"),88
"executable release script must not send replace:\n{executable}"89
);90
let _ = fs::remove_dir_all(&dir);91
}92
93
#[test]94
fn tag_publish_still_rejects_a_non_semver_tag_before_the_api_block() {95
let dir =96
std::env::temp_dir().join(format!("paperdoll-release-trap-bad-{}", std::process::id()));97
let _ = fs::remove_dir_all(&dir);98
fs::create_dir_all(&dir).unwrap();99
let log = dir.join("curl.log");100
install_curl_trap(&dir);101
let output = Command::new("bash")102
.arg(repo_root().join("scripts/ci/release.sh"))103
.env("PATH", path_prefix_env(&dir))104
.env("CURL_TRAP_LOG", &log)105
.env("CI_API_URL", "https://git.example.invalid/api/v1")106
.env("CI_JOB_TOKEN", "bgj_test_token_not_secret")107
.env("CI_COMMIT_SHA", "0123456789abcdef0123456789abcdef01234567")108
.env("CI_PROJECT_PATH", "golden-age/paperdoll-renderer")109
.env("CI_COMMIT_TAG", "not-a-version")110
.env_remove("PACKAGE_VERSION")111
.output()112
.unwrap();113
assert_ne!(output.status.code(), Some(0));114
assert_ne!(output.status.code(), Some(3));115
let stderr = String::from_utf8_lossy(&output.stderr);116
assert!(stderr.contains("package version must be X.Y.Z"), "{stderr}");117
assert!(!log.exists(), "curl was invoked for an invalid tag");118
let _ = fs::remove_dir_all(&dir);119
}