rickub/clipublic Fork
⑂ 25cc220
Commits
⬇ Clone ▾
git clone https://git.rickub.com/rickub/cli.git
git clone ssh://git@rickub.com/rickub/cli.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

ci: pin actions to cached SHAs and publish releases straight to the shimUnverified

Both workflows died in "Set up Go": a `uses: …@v5` tag makes the rickub
runner resolve the ref with an anonymous ls-remote against github.com,
which GitHub rate-limits per datacenter IP ("could not read Username for
'https://github.com'"). A full-SHA ref is served from the fleet's SHA-keyed
action cache with no GitHub round-trip, so checkout, setup-go and
upload-artifact are now pinned to the same SHAs rickub's own CI uses.

softprops/action-gh-release would have been the next cold fetch, for two
POSTs. scripts/publish-release.sh does them with curl against
$GITHUB_API_URL (the /gh shim) using the per-job GITHUB_TOKEN: create the
release at --target so the tag is cut there, then upload each asset via
the returned upload_url. It fails loudly on 422 (duplicate tag/asset) and
403 (the node proxy not forwarding /gh yet), keeps the token out of argv,
and is exercised against a fake shim locally. RELEASING.md now documents
the pinning rule, the script, and the server-side proxy requirement
(ogirardot/rickub#3) instead of a blocking prerequisite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgkcT3UPxwxukBKojB5eg4
Olivier Girardot committed 2026-09-02T21:09:17+02:00 Browse files
25cc220 parent: 30b5b18
modified .github/workflows/ci.yml +2 -2
@@ -23,10 +23,10 @@ jobs:
2323 runs-on: ubuntu-latest
2424 steps:
2525 - name: Checkout
26- uses: actions/checkout@v4
26+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
2727
2828 - name: Set up Go
29- uses: actions/setup-go@v5
29+ uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
3030 with:
3131 go-version-file: go.mod
3232 cache: true
@@ -23,10 +23,10 @@ jobs:
23 runs-on: ubuntu-latest23 runs-on: ubuntu-latest
24 steps:24 steps:
25 - name: Checkout25 - name: Checkout
26- uses: actions/checkout@v426+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
27 27
28 - name: Set up Go28 - name: Set up Go
29- uses: actions/setup-go@v529+ uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
30 with:30 with:
31 go-version-file: go.mod31 go-version-file: go.mod
32 cache: true32 cache: true
modified .github/workflows/rc.yml +12 -17
@@ -26,10 +26,10 @@ jobs:
2626 runs-on: ubuntu-latest
2727 steps:
2828 - name: Checkout
29- uses: actions/checkout@v4
29+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
3030
3131 - name: Set up Go
32- uses: actions/setup-go@v5
32+ uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
3333 with:
3434 go-version-file: go.mod
3535 cache: true
@@ -87,7 +87,7 @@ jobs:
8787 echo "path=${RUNNER_TEMP}/rc-notes.md" >> "$GITHUB_OUTPUT"
8888
8989 - name: Upload build artifacts
90- uses: actions/upload-artifact@v4
90+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
9191 with:
9292 name: rickub-${{ steps.version.outputs.version }}
9393 path: dist/
@@ -95,17 +95,12 @@ jobs:
9595 retention-days: 14
9696
9797 - name: Publish prerelease
98- uses: softprops/action-gh-release@v2
99- with:
100- tag_name: ${{ steps.version.outputs.tag }}
101- name: ${{ steps.version.outputs.tag }}
102- body_path: ${{ steps.notes.outputs.path }}
103- # The tag does not exist yet: the rickub release shim creates it at
104- # target_commitish, so CI cuts its own RC tags.
105- target_commitish: ${{ github.sha }}
106- draft: false
107- prerelease: true
108- files: |
109- dist/*.tar.gz
110- dist/SHA256SUMS
111- fail_on_unmatched_files: true
98+ env:
99+ TAG: ${{ steps.version.outputs.tag }}
100+ NOTES: ${{ steps.notes.outputs.path }}
101+ # The tag does not exist yet: the release shim creates it at --target,
102+ # so CI cuts its own RC tags.
103+ run: |
104+ bash scripts/publish-release.sh --prerelease \
105+ --tag "${TAG}" --target "${GITHUB_SHA}" --notes "${NOTES}" \
106+ dist/*.tar.gz dist/SHA256SUMS
@@ -26,10 +26,10 @@ jobs:
26 runs-on: ubuntu-latest26 runs-on: ubuntu-latest
27 steps:27 steps:
28 - name: Checkout28 - name: Checkout
29- uses: actions/checkout@v429+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
30 30
31 - name: Set up Go31 - name: Set up Go
32- uses: actions/setup-go@v532+ uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
33 with:33 with:
34 go-version-file: go.mod34 go-version-file: go.mod
35 cache: true35 cache: true
@@ -87,7 +87,7 @@ jobs:
87 echo "path=${RUNNER_TEMP}/rc-notes.md" >> "$GITHUB_OUTPUT"87 echo "path=${RUNNER_TEMP}/rc-notes.md" >> "$GITHUB_OUTPUT"
88 88
89 - name: Upload build artifacts89 - name: Upload build artifacts
90- uses: actions/upload-artifact@v490+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
91 with:91 with:
92 name: rickub-${{ steps.version.outputs.version }}92 name: rickub-${{ steps.version.outputs.version }}
93 path: dist/93 path: dist/
@@ -95,17 +95,12 @@ jobs:
95 retention-days: 1495 retention-days: 14
96 96
97 - name: Publish prerelease97 - name: Publish prerelease
98- uses: softprops/action-gh-release@v298+ env:
99- with:99+ TAG: ${{ steps.version.outputs.tag }}
100- tag_name: ${{ steps.version.outputs.tag }}100+ NOTES: ${{ steps.notes.outputs.path }}
101- name: ${{ steps.version.outputs.tag }}101+ # The tag does not exist yet: the release shim creates it at --target,
102- body_path: ${{ steps.notes.outputs.path }}102+ # so CI cuts its own RC tags.
103- # The tag does not exist yet: the rickub release shim creates it at103+ run: |
104- # target_commitish, so CI cuts its own RC tags.104+ bash scripts/publish-release.sh --prerelease \
105- target_commitish: ${{ github.sha }}105+ --tag "${TAG}" --target "${GITHUB_SHA}" --notes "${NOTES}" \
106- draft: false106+ dist/*.tar.gz dist/SHA256SUMS
107- prerelease: true
108- files: |
109- dist/*.tar.gz
110- dist/SHA256SUMS
111- fail_on_unmatched_files: true
modified .github/workflows/release.yml +12 -17
@@ -36,7 +36,7 @@ jobs:
3636 runs-on: ubuntu-latest
3737 steps:
3838 - name: Checkout
39- uses: actions/checkout@v4
39+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
4040 with:
4141 # Full history + tags so the "tag already exists" check is meaningful.
4242 fetch-depth: 0
@@ -133,7 +133,7 @@ jobs:
133133 esac
134134
135135 - name: Set up Go
136- uses: actions/setup-go@v5
136+ uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
137137 with:
138138 go-version-file: go.mod
139139 cache: true
@@ -178,7 +178,7 @@ jobs:
178178 echo "path=${RUNNER_TEMP}/release-notes.md" >> "$GITHUB_OUTPUT"
179179
180180 - name: Upload build artifacts
181- uses: actions/upload-artifact@v4
181+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
182182 with:
183183 name: rickub-${{ steps.check.outputs.version }}
184184 path: dist/
@@ -187,20 +187,15 @@ jobs:
187187
188188 - name: Publish release
189189 if: steps.check.outputs.dry_run != 'true'
190- uses: softprops/action-gh-release@v2
191- with:
192- tag_name: ${{ steps.check.outputs.tag }}
193- name: ${{ steps.check.outputs.tag }}
194- body_path: ${{ steps.notes.outputs.path }}
195- # The tag does not exist yet: the rickub release shim creates it at
196- # target_commitish, so the dispatched ref's commit is what gets tagged.
197- target_commitish: ${{ github.sha }}
198- draft: false
199- prerelease: false
200- files: |
201- dist/*.tar.gz
202- dist/SHA256SUMS
203- fail_on_unmatched_files: true
190+ env:
191+ TAG: ${{ steps.check.outputs.tag }}
192+ NOTES: ${{ steps.notes.outputs.path }}
193+ # The tag does not exist yet: the release shim creates it at --target,
194+ # so the dispatched ref's commit is what gets tagged.
195+ run: |
196+ bash scripts/publish-release.sh \
197+ --tag "${TAG}" --target "${GITHUB_SHA}" --notes "${NOTES}" \
198+ dist/*.tar.gz dist/SHA256SUMS
204199
205200 - name: Dry run summary
206201 if: steps.check.outputs.dry_run == 'true'
@@ -36,7 +36,7 @@ jobs:
36 runs-on: ubuntu-latest36 runs-on: ubuntu-latest
37 steps:37 steps:
38 - name: Checkout38 - name: Checkout
39- uses: actions/checkout@v439+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
40 with:40 with:
41 # Full history + tags so the "tag already exists" check is meaningful.41 # Full history + tags so the "tag already exists" check is meaningful.
42 fetch-depth: 042 fetch-depth: 0
@@ -133,7 +133,7 @@ jobs:
133 esac133 esac
134 134
135 - name: Set up Go135 - name: Set up Go
136- uses: actions/setup-go@v5136+ uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
137 with:137 with:
138 go-version-file: go.mod138 go-version-file: go.mod
139 cache: true139 cache: true
@@ -178,7 +178,7 @@ jobs:
178 echo "path=${RUNNER_TEMP}/release-notes.md" >> "$GITHUB_OUTPUT"178 echo "path=${RUNNER_TEMP}/release-notes.md" >> "$GITHUB_OUTPUT"
179 179
180 - name: Upload build artifacts180 - name: Upload build artifacts
181- uses: actions/upload-artifact@v4181+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
182 with:182 with:
183 name: rickub-${{ steps.check.outputs.version }}183 name: rickub-${{ steps.check.outputs.version }}
184 path: dist/184 path: dist/
@@ -187,20 +187,15 @@ jobs:
187 187
188 - name: Publish release188 - name: Publish release
189 if: steps.check.outputs.dry_run != 'true'189 if: steps.check.outputs.dry_run != 'true'
190- uses: softprops/action-gh-release@v2190+ env:
191- with:191+ TAG: ${{ steps.check.outputs.tag }}
192- tag_name: ${{ steps.check.outputs.tag }}192+ NOTES: ${{ steps.notes.outputs.path }}
193- name: ${{ steps.check.outputs.tag }}193+ # The tag does not exist yet: the release shim creates it at --target,
194- body_path: ${{ steps.notes.outputs.path }}194+ # so the dispatched ref's commit is what gets tagged.
195- # The tag does not exist yet: the rickub release shim creates it at195+ run: |
196- # target_commitish, so the dispatched ref's commit is what gets tagged.196+ bash scripts/publish-release.sh \
197- target_commitish: ${{ github.sha }}197+ --tag "${TAG}" --target "${GITHUB_SHA}" --notes "${NOTES}" \
198- draft: false198+ dist/*.tar.gz dist/SHA256SUMS
199- prerelease: false
200- files: |
201- dist/*.tar.gz
202- dist/SHA256SUMS
203- fail_on_unmatched_files: true
204 199
205 - name: Dry run summary200 - name: Dry run summary
206 if: steps.check.outputs.dry_run == 'true'201 if: steps.check.outputs.dry_run == 'true'
modified RELEASING.md +64 -57
@@ -9,51 +9,52 @@ GitHub-Actions-compatible CI:
99 | [`.github/workflows/rc.yml`](.github/workflows/rc.yml) | every push to `main` | tests, cross-compiles all four targets, publishes a **prerelease** `v<BASE>-rc.<run_number>` |
1010 | [`.github/workflows/release.yml`](.github/workflows/release.yml) | **manual only** (`workflow_dispatch`) | tests, cross-compiles, publishes a real release `v<version>` |
1111
12-Both release-producing workflows call the same script,
13-[`scripts/build-dist.sh`](scripts/build-dist.sh), so an RC and a real release
14-are byte-for-byte the same pipeline with a different version stamp.
12+Both release-producing workflows call the same two scripts,
13+[`scripts/build-dist.sh`](scripts/build-dist.sh) to build and
14+[`scripts/publish-release.sh`](scripts/publish-release.sh) to publish, so an RC
15+and a real release are byte-for-byte the same pipeline with a different version
16+stamp and the `--prerelease` flag.
1517
1618 ---
1719
18-## ⚠️ Prerequisite: the CI proxy must allow `/gh/`
19-
20-**Release publishing from CI fails until an operator changes the rickub server.**
21-
22-The CI guest reaches the outside world through `web/ciproxy`, whose allowlist
23-today only covers the artifact/cache results service. The release REST shim
24-lives under the `/gh` prefix (`$GITHUB_API_URL`), and the proxy currently
25-answers **403** for those paths, so `softprops/action-gh-release@v2` cannot
26-create the release or upload assets.
27-
28-To unblock it, in the **rickhub server repo**:
29-
30-1. `web/ciproxy/proxy.go` — add the release shim prefix to `allowedPathPrefixes`:
31-
32- ```go
33- var allowedPathPrefixes = []string{
34- "/twirp/github.actions.results.api.v1.ArtifactService/",
35- "/twirp/github.actions.results.api.v1.CacheService/",
36- "/gh/", // GitHub-compatible REST shim: releases + asset upload
37- }
38- ```
39-
40-2. `web/ciproxy/proxy.go` — `allowedMethods` is currently
41- `GET / HEAD / POST / PUT` only. Release *creation* and asset upload are
42- `POST`, but editing a release is `PATCH` and removing an asset is `DELETE`;
43- add whichever verbs you intend to support.
44-
45-3. `web/ciproxy/proxy_test.go` — add coverage for the new prefix: a `/gh/repos/…`
46- path is allowed, a non-`/gh` path is still denied, and path traversal such as
47- `/gh/../login` is still refused (`Allowed` rejects anything non-canonical).
48- Also re-run `web/ci_proxy_allowlist_conformance_test.go`, which cross-checks
49- the allowlist against the registered mux routes.
50-
51-Until that ships, `rc.yml` and `release.yml` will build and upload their
52-`actions/upload-artifact` bundle successfully and then fail on the
53-"Publish …" step. The archives are still downloadable from the run's artifacts,
54-so you can release by hand in the meantime.
55-
56-You can rehearse the whole pipeline without touching the shim by dispatching
20+## How the pipeline reaches the release API
21+
22+Two things about rickub's CI shape this repository's workflows; both are
23+enforced in the files, and both bite as a failed step if forgotten.
24+
25+**`uses:` actions are pinned to commit SHAs, not tags.** The runner fetches
26+every action from github.com. A full-SHA ref is served from the fleet's
27+SHA-keyed action cache with no GitHub round-trip; a tag such as `@v5` first
28+needs an anonymous `ls-remote` against github.com, which GitHub rate-limits per
29+datacenter IP — the run then dies in "Set up Go" with
30+`could not read Username for 'https://github.com'`. The three actions used here
31+(`actions/checkout`, `actions/setup-go`, `actions/upload-artifact`) are pinned
32+to the same SHAs as rickub's own `.rickub/workflows/ci.yml`, so they are always
33+warm in that cache. When bumping one, pin the new SHA and add the `# vN`
34+comment.
35+
36+**Releases are published by [`scripts/publish-release.sh`](scripts/publish-release.sh),
37+not by a marketplace action.** It POSTs the release, then each asset, to the
38+GitHub-compatible REST shim the job is handed as `$GITHUB_API_URL`
39+(`…/gh`), authenticated with the per-job `GITHUB_TOKEN`. Two `curl` calls do
40+not justify fetching `softprops/action-gh-release` (which would be a cold,
41+rate-limited GitHub fetch), and the shim's exact contract — `target_commitish`
42+cuts the tag, `422` on a duplicate release or asset name, `upload_url`
43+template — is handled in one readable place. Both `rc.yml` and `release.yml`
44+call it; a real release and a candidate differ only in `--prerelease`.
45+
46+**Server-side requirement (rickub, not this repo).** The CI guest reaches the
47+control plane only through the node's results proxy (`web/ciproxy` in the
48+rickub server), and `$GITHUB_API_URL` points at that proxy. The proxy must
49+forward the `/gh/` prefix — shipped in rickub PR
50+[ogirardot/rickub#3](https://github.com/ogirardot/rickub/pull/3) and active on a
51+node once its `rickub-fleetd` has been rebuilt from that change. On a node
52+running an older fleetd, both workflows build, test and upload their
53+`actions/upload-artifact` bundle, then fail on the "Publish …" step with
54+`HTTP 403 creating the release`; the archives are still downloadable from the
55+run's artifacts, so a release can be published by hand in the meantime.
56+
57+You can rehearse the whole pipeline without publishing by dispatching
5758 `release.yml` with **`dry_run: true`** — it builds, tests, verifies the tag is
5859 free and uploads the artifacts, but never calls the release API.
5960
@@ -86,12 +87,13 @@ Every push to `main` runs `rc.yml`:
8687
8788 1. `go test ./...`
8889 2. `scripts/build-dist.sh <BASE>-rc.<run_number>`
89-3. publishes a **prerelease** at tag `v<BASE>-rc.<run_number>`, with the four
90- `.tar.gz` archives and `SHA256SUMS` attached.
90+3. `scripts/publish-release.sh --prerelease …` publishes a **prerelease** at
91+ tag `v<BASE>-rc.<run_number>`, with the four `.tar.gz` archives and
92+ `SHA256SUMS` attached.
9193
9294 The tag does not exist beforehand — the release shim creates it at
93-`target_commitish`, which the workflow sets to `${{ github.sha }}`, so CI cuts
94-its own RC tags.
95+`target_commitish`, which the script is given as `${{ github.sha }}`, so CI
96+cuts its own RC tags.
9597
9698 `concurrency: { group: rc, cancel-in-progress: true }` means only the newest
9799 push to `main` is building at any moment; superseded RC runs are cancelled, so
@@ -122,9 +124,9 @@ The workflow then:
122124 non-404 answer is only a warning — the git tag check is the real gate);
123125 * runs `go vet` and `go test`;
124126 * builds the four archives + `SHA256SUMS` via `scripts/build-dist.sh`;
125-* publishes a non-prerelease release at `v<version>`, targeting
126- `${{ github.sha }}` — the exact commit of the ref you dispatched — so the tag
127- is created at that commit.
127+* runs `scripts/publish-release.sh` to publish a non-prerelease release at
128+ `v<version>`, targeting `${{ github.sha }}` — the exact commit of the ref you
129+ dispatched — so the tag is created at that commit.
128130
129131 There is no approval gate: rickub CI silently drops `environment:`, so the
130132 permission to run this workflow *is* the permission to release.
@@ -207,20 +209,25 @@ leaves nothing untracked behind — `dist/` is already in `.gitignore`.
207209
208210 ## Troubleshooting
209211
210-**`403` publishing the release.** The `/gh` ciproxy allowlist prerequisite at
211-the top of this document has not landed yet.
212+**`HTTP 403 creating the release`.** The node's CI proxy does not forward
213+`/gh/` yet — see "Server-side requirement" above.
214+
215+**`could not read Username for 'https://github.com'` in "Set up Go".** A
216+`uses:` reference is a tag, not a pinned SHA — see the pinning note above.
212217
213218 **`Resource not accessible by integration` / `404` on release creation.** The
214219 `GITHUB_TOKEN` is read-only by default; the workflow must declare
215220 `permissions: { contents: write }`. Both `rc.yml` and `release.yml` do.
216221
217-**`422` uploading an asset.** An asset with that name already exists on the
218-release — the shim rejects duplicates. This normally means a partially
219-completed run is being retried; delete the release (or the asset) and re-run.
222+**`422` creating the release or uploading an asset.** A release for that tag,
223+or an asset with that name, already exists — the shim rejects duplicates. This
224+normally means a partially completed run is being retried; delete the release
225+(or the asset) and re-run.
220226
221-**Editing a release via `PATCH`.** The shim reads `draft` and `prerelease`
222-unconditionally, so **always send both fields** in a PATCH body or you will
223-silently flip a release to draft.
227+**Editing a release via `PATCH`.** The workflows never edit a release, but if
228+you script one: the shim reads `draft` and `prerelease` unconditionally, so
229+**always send both fields** in a PATCH body or you will silently flip a
230+release to draft.
224231
225232 **Nothing to release / `make_latest` ignored.** The shim does not implement
226233 `make_latest` or `generate_release_notes`, and there is no
@@ -9,51 +9,52 @@ GitHub-Actions-compatible CI:
9 | [`.github/workflows/rc.yml`](.github/workflows/rc.yml) | every push to `main` | tests, cross-compiles all four targets, publishes a **prerelease** `v<BASE>-rc.<run_number>` |9 | [`.github/workflows/rc.yml`](.github/workflows/rc.yml) | every push to `main` | tests, cross-compiles all four targets, publishes a **prerelease** `v<BASE>-rc.<run_number>` |
10 | [`.github/workflows/release.yml`](.github/workflows/release.yml) | **manual only** (`workflow_dispatch`) | tests, cross-compiles, publishes a real release `v<version>` |10 | [`.github/workflows/release.yml`](.github/workflows/release.yml) | **manual only** (`workflow_dispatch`) | tests, cross-compiles, publishes a real release `v<version>` |
11 11
12-Both release-producing workflows call the same script,12+Both release-producing workflows call the same two scripts,
13-[`scripts/build-dist.sh`](scripts/build-dist.sh), so an RC and a real release13+[`scripts/build-dist.sh`](scripts/build-dist.sh) to build and
14-are byte-for-byte the same pipeline with a different version stamp.14+[`scripts/publish-release.sh`](scripts/publish-release.sh) to publish, so an RC
15+and a real release are byte-for-byte the same pipeline with a different version
16+stamp and the `--prerelease` flag.
15 17
16 ---18 ---
17 19
18-## ⚠️ Prerequisite: the CI proxy must allow `/gh/`20+## How the pipeline reaches the release API
19-21+
20-**Release publishing from CI fails until an operator changes the rickub server.**22+Two things about rickub's CI shape this repository's workflows; both are
21-23+enforced in the files, and both bite as a failed step if forgotten.
22-The CI guest reaches the outside world through `web/ciproxy`, whose allowlist24+
23-today only covers the artifact/cache results service. The release REST shim25+**`uses:` actions are pinned to commit SHAs, not tags.** The runner fetches
24-lives under the `/gh` prefix (`$GITHUB_API_URL`), and the proxy currently26+every action from github.com. A full-SHA ref is served from the fleet's
25-answers **403** for those paths, so `softprops/action-gh-release@v2` cannot27+SHA-keyed action cache with no GitHub round-trip; a tag such as `@v5` first
26-create the release or upload assets.28+needs an anonymous `ls-remote` against github.com, which GitHub rate-limits per
27-29+datacenter IP — the run then dies in "Set up Go" with
28-To unblock it, in the **rickhub server repo**:30+`could not read Username for 'https://github.com'`. The three actions used here
29-31+(`actions/checkout`, `actions/setup-go`, `actions/upload-artifact`) are pinned
30-1. `web/ciproxy/proxy.go` — add the release shim prefix to `allowedPathPrefixes`:32+to the same SHAs as rickub's own `.rickub/workflows/ci.yml`, so they are always
31-33+warm in that cache. When bumping one, pin the new SHA and add the `# vN`
32- ```go34+comment.
33- var allowedPathPrefixes = []string{35+
34- "/twirp/github.actions.results.api.v1.ArtifactService/",36+**Releases are published by [`scripts/publish-release.sh`](scripts/publish-release.sh),
35- "/twirp/github.actions.results.api.v1.CacheService/",37+not by a marketplace action.** It POSTs the release, then each asset, to the
36- "/gh/", // GitHub-compatible REST shim: releases + asset upload38+GitHub-compatible REST shim the job is handed as `$GITHUB_API_URL`
37- }39+(`…/gh`), authenticated with the per-job `GITHUB_TOKEN`. Two `curl` calls do
38- ```40+not justify fetching `softprops/action-gh-release` (which would be a cold,
39-41+rate-limited GitHub fetch), and the shim's exact contract — `target_commitish`
40-2. `web/ciproxy/proxy.go` — `allowedMethods` is currently42+cuts the tag, `422` on a duplicate release or asset name, `upload_url`
41- `GET / HEAD / POST / PUT` only. Release *creation* and asset upload are43+template — is handled in one readable place. Both `rc.yml` and `release.yml`
42- `POST`, but editing a release is `PATCH` and removing an asset is `DELETE`;44+call it; a real release and a candidate differ only in `--prerelease`.
43- add whichever verbs you intend to support.45+
44-46+**Server-side requirement (rickub, not this repo).** The CI guest reaches the
45-3. `web/ciproxy/proxy_test.go` — add coverage for the new prefix: a `/gh/repos/…`47+control plane only through the node's results proxy (`web/ciproxy` in the
46- path is allowed, a non-`/gh` path is still denied, and path traversal such as48+rickub server), and `$GITHUB_API_URL` points at that proxy. The proxy must
47- `/gh/../login` is still refused (`Allowed` rejects anything non-canonical).49+forward the `/gh/` prefix — shipped in rickub PR
48- Also re-run `web/ci_proxy_allowlist_conformance_test.go`, which cross-checks50+[ogirardot/rickub#3](https://github.com/ogirardot/rickub/pull/3) and active on a
49- the allowlist against the registered mux routes.51+node once its `rickub-fleetd` has been rebuilt from that change. On a node
50-52+running an older fleetd, both workflows build, test and upload their
51-Until that ships, `rc.yml` and `release.yml` will build and upload their53+`actions/upload-artifact` bundle, then fail on the "Publish …" step with
52-`actions/upload-artifact` bundle successfully and then fail on the54+`HTTP 403 creating the release`; the archives are still downloadable from the
53-"Publish …" step. The archives are still downloadable from the run's artifacts,55+run's artifacts, so a release can be published by hand in the meantime.
54-so you can release by hand in the meantime.56+
55-57+You can rehearse the whole pipeline without publishing by dispatching
56-You can rehearse the whole pipeline without touching the shim by dispatching
57 `release.yml` with **`dry_run: true`** — it builds, tests, verifies the tag is58 `release.yml` with **`dry_run: true`** — it builds, tests, verifies the tag is
58 free and uploads the artifacts, but never calls the release API.59 free and uploads the artifacts, but never calls the release API.
59 60
@@ -86,12 +87,13 @@ Every push to `main` runs `rc.yml`:
86 87
87 1. `go test ./...`88 1. `go test ./...`
88 2. `scripts/build-dist.sh <BASE>-rc.<run_number>`89 2. `scripts/build-dist.sh <BASE>-rc.<run_number>`
89-3. publishes a **prerelease** at tag `v<BASE>-rc.<run_number>`, with the four90+3. `scripts/publish-release.sh --prerelease …` publishes a **prerelease** at
90- `.tar.gz` archives and `SHA256SUMS` attached.91+ tag `v<BASE>-rc.<run_number>`, with the four `.tar.gz` archives and
92+ `SHA256SUMS` attached.
91 93
92 The tag does not exist beforehand — the release shim creates it at94 The tag does not exist beforehand — the release shim creates it at
93-`target_commitish`, which the workflow sets to `${{ github.sha }}`, so CI cuts95+`target_commitish`, which the script is given as `${{ github.sha }}`, so CI
94-its own RC tags.96+cuts its own RC tags.
95 97
96 `concurrency: { group: rc, cancel-in-progress: true }` means only the newest98 `concurrency: { group: rc, cancel-in-progress: true }` means only the newest
97 push to `main` is building at any moment; superseded RC runs are cancelled, so99 push to `main` is building at any moment; superseded RC runs are cancelled, so
@@ -122,9 +124,9 @@ The workflow then:
122 non-404 answer is only a warning — the git tag check is the real gate);124 non-404 answer is only a warning — the git tag check is the real gate);
123 * runs `go vet` and `go test`;125 * runs `go vet` and `go test`;
124 * builds the four archives + `SHA256SUMS` via `scripts/build-dist.sh`;126 * builds the four archives + `SHA256SUMS` via `scripts/build-dist.sh`;
125-* publishes a non-prerelease release at `v<version>`, targeting127+* runs `scripts/publish-release.sh` to publish a non-prerelease release at
126- `${{ github.sha }}` — the exact commit of the ref you dispatched — so the tag128+ `v<version>`, targeting `${{ github.sha }}` — the exact commit of the ref you
127- is created at that commit.129+ dispatched — so the tag is created at that commit.
128 130
129 There is no approval gate: rickub CI silently drops `environment:`, so the131 There is no approval gate: rickub CI silently drops `environment:`, so the
130 permission to run this workflow *is* the permission to release.132 permission to run this workflow *is* the permission to release.
@@ -207,20 +209,25 @@ leaves nothing untracked behind — `dist/` is already in `.gitignore`.
207 209
208 ## Troubleshooting210 ## Troubleshooting
209 211
210-**`403` publishing the release.** The `/gh` ciproxy allowlist prerequisite at212+**`HTTP 403 creating the release`.** The node's CI proxy does not forward
211-the top of this document has not landed yet.213+`/gh/` yet — see "Server-side requirement" above.
214+
215+**`could not read Username for 'https://github.com'` in "Set up Go".** A
216+`uses:` reference is a tag, not a pinned SHA — see the pinning note above.
212 217
213 **`Resource not accessible by integration` / `404` on release creation.** The218 **`Resource not accessible by integration` / `404` on release creation.** The
214 `GITHUB_TOKEN` is read-only by default; the workflow must declare219 `GITHUB_TOKEN` is read-only by default; the workflow must declare
215 `permissions: { contents: write }`. Both `rc.yml` and `release.yml` do.220 `permissions: { contents: write }`. Both `rc.yml` and `release.yml` do.
216 221
217-**`422` uploading an asset.** An asset with that name already exists on the222+**`422` creating the release or uploading an asset.** A release for that tag,
218-release — the shim rejects duplicates. This normally means a partially223+or an asset with that name, already exists — the shim rejects duplicates. This
219-completed run is being retried; delete the release (or the asset) and re-run.224+normally means a partially completed run is being retried; delete the release
225+(or the asset) and re-run.
220 226
221-**Editing a release via `PATCH`.** The shim reads `draft` and `prerelease`227+**Editing a release via `PATCH`.** The workflows never edit a release, but if
222-unconditionally, so **always send both fields** in a PATCH body or you will228+you script one: the shim reads `draft` and `prerelease` unconditionally, so
223-silently flip a release to draft.229+**always send both fields** in a PATCH body or you will silently flip a
230+release to draft.
224 231
225 **Nothing to release / `make_latest` ignored.** The shim does not implement232 **Nothing to release / `make_latest` ignored.** The shim does not implement
226 `make_latest` or `generate_release_notes`, and there is no233 `make_latest` or `generate_release_notes`, and there is no
added scripts/publish-release.sh +110 -0
new file mode 100755
@@ -0,0 +1,110 @@
1+#!/usr/bin/env bash
2+#
3+# publish-release.sh — create a release on the rickub host and attach assets,
4+# straight against the GitHub-compatible REST shim the CI job is handed.
5+#
6+# Usage: scripts/publish-release.sh --tag v1.2.3 --target <sha> --notes notes.md \
7+# [--name v1.2.3] [--prerelease] [--draft] file...
8+#
9+# Environment (all injected by rickub CI; set them by hand to run elsewhere):
10+# GITHUB_API_URL shim base, e.g. http://172.30.255.1:8888/gh
11+# GITHUB_TOKEN the per-job token (contents: write)
12+# GITHUB_REPOSITORY owner/repo
13+#
14+# Why a script and not softprops/action-gh-release: rickub's runner fetches every
15+# `uses:` action from github.com, and an action that is not already in the fleet's
16+# SHA-keyed cache costs an anonymous GitHub fetch that github.com rate-limits per
17+# datacenter IP. The two calls the release needs — POST the release, POST each
18+# asset — are trivial with curl, and this way the release pipeline depends on
19+# nothing that has to be fetched at run time. It needs curl and jq, both present
20+# in the CI guest.
21+
22+set -euo pipefail
23+
24+usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" >&2; exit 2; }
25+
26+tag=""; target=""; notes=""; name=""; prerelease=false; draft=false
27+files=()
28+while [ $# -gt 0 ]; do
29+ case "$1" in
30+ --tag) tag="$2"; shift 2 ;;
31+ --target) target="$2"; shift 2 ;;
32+ --notes) notes="$2"; shift 2 ;;
33+ --name) name="$2"; shift 2 ;;
34+ --prerelease) prerelease=true; shift ;;
35+ --draft) draft=true; shift ;;
36+ -h|--help) usage ;;
37+ --) shift; files+=("$@"); break ;;
38+ -*) echo "publish-release.sh: unknown flag $1" >&2; usage ;;
39+ *) files+=("$1"); shift ;;
40+ esac
41+done
42+
43+[ -n "${tag}" ] || { echo "publish-release.sh: --tag is required" >&2; exit 2; }
44+[ -n "${notes}" ] && [ -f "${notes}" ] || { echo "publish-release.sh: --notes must name a file" >&2; exit 2; }
45+[ "${#files[@]}" -gt 0 ] || { echo "publish-release.sh: no asset files given" >&2; exit 2; }
46+for f in "${files[@]}"; do
47+ [ -f "${f}" ] || { echo "publish-release.sh: asset not found: ${f}" >&2; exit 2; }
48+done
49+for v in GITHUB_API_URL GITHUB_TOKEN GITHUB_REPOSITORY; do
50+ [ -n "${!v:-}" ] || { echo "publish-release.sh: ${v} is not set" >&2; exit 2; }
51+done
52+command -v curl >/dev/null || { echo "publish-release.sh: curl not found" >&2; exit 2; }
53+command -v jq >/dev/null || { echo "publish-release.sh: jq not found" >&2; exit 2; }
54+
55+api="${GITHUB_API_URL%/}"
56+name="${name:-${tag}}"
57+
58+# Never echo the token: -H is passed through a config file, not the command line
59+# (the guest's own process list is untrusted-code territory).
60+auth_cfg="$(mktemp)"
61+trap 'rm -f "${auth_cfg}"' EXIT
62+printf 'header = "Authorization: Bearer %s"\n' "${GITHUB_TOKEN}" > "${auth_cfg}"
63+
64+body="$(mktemp)"
65+trap 'rm -f "${auth_cfg}" "${body}"' EXIT
66+
67+# request METHOD URL [curl args...]: the response body lands in ${body}, the
68+# HTTP status is printed (000 when curl itself failed).
69+request() {
70+ local method="$1" url="$2"; shift 2
71+ curl -sS -o "${body}" -w '%{http_code}' -K "${auth_cfg}" \
72+ -H 'Accept: application/vnd.github+json' -X "${method}" "$@" "${url}" || true
73+}
74+message() { jq -r '.message // .' "${body}" 2>/dev/null || cat "${body}"; }
75+
76+echo "==> creating release ${tag} on ${GITHUB_REPOSITORY} (prerelease=${prerelease}, draft=${draft})"
77+payload="$(jq -n \
78+ --arg tag "${tag}" --arg target "${target}" --arg name "${name}" \
79+ --rawfile body "${notes}" --argjson prerelease "${prerelease}" --argjson draft "${draft}" \
80+ '{tag_name: $tag, name: $name, body: $body, draft: $draft, prerelease: $prerelease}
81+ + (if $target == "" then {} else {target_commitish: $target} end)')"
82+status="$(request POST "${api}/repos/${GITHUB_REPOSITORY}/releases" \
83+ -H 'Content-Type: application/json' --data-binary "${payload}")"
84+case "${status}" in
85+ 200|201) ;;
86+ 422) echo "::error::release ${tag} was refused (HTTP 422): $(message)"; exit 1 ;;
87+ 403) echo "::error::HTTP 403 creating the release — the node's CI proxy does not forward ${api}; see RELEASING.md"; exit 1 ;;
88+ *) echo "::error::creating release ${tag} failed (HTTP ${status}): $(message)"; exit 1 ;;
89+esac
90+release_id="$(jq -r '.id // empty' "${body}")"
91+upload_url="$(jq -r '.upload_url // empty' "${body}" | sed 's/{[^}]*}$//')"
92+html_url="$(jq -r '.html_url // empty' "${body}")"
93+[ -n "${release_id}" ] || { echo "::error::release response carried no id: $(cat "${body}")"; exit 1; }
94+[ -n "${upload_url}" ] || { echo "::error::release response carried no upload_url: $(cat "${body}")"; exit 1; }
95+echo " release id ${release_id}${html_url:+ — ${html_url}}"
96+
97+for f in "${files[@]}"; do
98+ asset="$(basename "${f}")"
99+ echo "==> uploading ${asset} ($(wc -c < "${f}" | tr -d ' ') bytes)"
100+ encoded="$(jq -rn --arg n "${asset}" '$n|@uri')"
101+ status="$(request POST "${upload_url}?name=${encoded}" \
102+ -H 'Content-Type: application/octet-stream' --data-binary "@${f}")"
103+ case "${status}" in
104+ 200|201) ;;
105+ 422) echo "::error::asset ${asset} was refused (HTTP 422): $(message) — a partial earlier run? delete the release and retry"; exit 1 ;;
106+ *) echo "::error::uploading ${asset} failed (HTTP ${status}): $(message)"; exit 1 ;;
107+ esac
108+done
109+
110+echo "==> published ${tag} with ${#files[@]} asset(s)"
new file mode 100755
@@ -0,0 +1,110 @@
1+#!/usr/bin/env bash
2+#
3+# publish-release.sh — create a release on the rickub host and attach assets,
4+# straight against the GitHub-compatible REST shim the CI job is handed.
5+#
6+# Usage: scripts/publish-release.sh --tag v1.2.3 --target <sha> --notes notes.md \
7+# [--name v1.2.3] [--prerelease] [--draft] file...
8+#
9+# Environment (all injected by rickub CI; set them by hand to run elsewhere):
10+# GITHUB_API_URL shim base, e.g. http://172.30.255.1:8888/gh
11+# GITHUB_TOKEN the per-job token (contents: write)
12+# GITHUB_REPOSITORY owner/repo
13+#
14+# Why a script and not softprops/action-gh-release: rickub's runner fetches every
15+# `uses:` action from github.com, and an action that is not already in the fleet's
16+# SHA-keyed cache costs an anonymous GitHub fetch that github.com rate-limits per
17+# datacenter IP. The two calls the release needs — POST the release, POST each
18+# asset — are trivial with curl, and this way the release pipeline depends on
19+# nothing that has to be fetched at run time. It needs curl and jq, both present
20+# in the CI guest.
21+
22+set -euo pipefail
23+
24+usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" >&2; exit 2; }
25+
26+tag=""; target=""; notes=""; name=""; prerelease=false; draft=false
27+files=()
28+while [ $# -gt 0 ]; do
29+ case "$1" in
30+ --tag) tag="$2"; shift 2 ;;
31+ --target) target="$2"; shift 2 ;;
32+ --notes) notes="$2"; shift 2 ;;
33+ --name) name="$2"; shift 2 ;;
34+ --prerelease) prerelease=true; shift ;;
35+ --draft) draft=true; shift ;;
36+ -h|--help) usage ;;
37+ --) shift; files+=("$@"); break ;;
38+ -*) echo "publish-release.sh: unknown flag $1" >&2; usage ;;
39+ *) files+=("$1"); shift ;;
40+ esac
41+done
42+
43+[ -n "${tag}" ] || { echo "publish-release.sh: --tag is required" >&2; exit 2; }
44+[ -n "${notes}" ] && [ -f "${notes}" ] || { echo "publish-release.sh: --notes must name a file" >&2; exit 2; }
45+[ "${#files[@]}" -gt 0 ] || { echo "publish-release.sh: no asset files given" >&2; exit 2; }
46+for f in "${files[@]}"; do
47+ [ -f "${f}" ] || { echo "publish-release.sh: asset not found: ${f}" >&2; exit 2; }
48+done
49+for v in GITHUB_API_URL GITHUB_TOKEN GITHUB_REPOSITORY; do
50+ [ -n "${!v:-}" ] || { echo "publish-release.sh: ${v} is not set" >&2; exit 2; }
51+done
52+command -v curl >/dev/null || { echo "publish-release.sh: curl not found" >&2; exit 2; }
53+command -v jq >/dev/null || { echo "publish-release.sh: jq not found" >&2; exit 2; }
54+
55+api="${GITHUB_API_URL%/}"
56+name="${name:-${tag}}"
57+
58+# Never echo the token: -H is passed through a config file, not the command line
59+# (the guest's own process list is untrusted-code territory).
60+auth_cfg="$(mktemp)"
61+trap 'rm -f "${auth_cfg}"' EXIT
62+printf 'header = "Authorization: Bearer %s"\n' "${GITHUB_TOKEN}" > "${auth_cfg}"
63+
64+body="$(mktemp)"
65+trap 'rm -f "${auth_cfg}" "${body}"' EXIT
66+
67+# request METHOD URL [curl args...]: the response body lands in ${body}, the
68+# HTTP status is printed (000 when curl itself failed).
69+request() {
70+ local method="$1" url="$2"; shift 2
71+ curl -sS -o "${body}" -w '%{http_code}' -K "${auth_cfg}" \
72+ -H 'Accept: application/vnd.github+json' -X "${method}" "$@" "${url}" || true
73+}
74+message() { jq -r '.message // .' "${body}" 2>/dev/null || cat "${body}"; }
75+
76+echo "==> creating release ${tag} on ${GITHUB_REPOSITORY} (prerelease=${prerelease}, draft=${draft})"
77+payload="$(jq -n \
78+ --arg tag "${tag}" --arg target "${target}" --arg name "${name}" \
79+ --rawfile body "${notes}" --argjson prerelease "${prerelease}" --argjson draft "${draft}" \
80+ '{tag_name: $tag, name: $name, body: $body, draft: $draft, prerelease: $prerelease}
81+ + (if $target == "" then {} else {target_commitish: $target} end)')"
82+status="$(request POST "${api}/repos/${GITHUB_REPOSITORY}/releases" \
83+ -H 'Content-Type: application/json' --data-binary "${payload}")"
84+case "${status}" in
85+ 200|201) ;;
86+ 422) echo "::error::release ${tag} was refused (HTTP 422): $(message)"; exit 1 ;;
87+ 403) echo "::error::HTTP 403 creating the release — the node's CI proxy does not forward ${api}; see RELEASING.md"; exit 1 ;;
88+ *) echo "::error::creating release ${tag} failed (HTTP ${status}): $(message)"; exit 1 ;;
89+esac
90+release_id="$(jq -r '.id // empty' "${body}")"
91+upload_url="$(jq -r '.upload_url // empty' "${body}" | sed 's/{[^}]*}$//')"
92+html_url="$(jq -r '.html_url // empty' "${body}")"
93+[ -n "${release_id}" ] || { echo "::error::release response carried no id: $(cat "${body}")"; exit 1; }
94+[ -n "${upload_url}" ] || { echo "::error::release response carried no upload_url: $(cat "${body}")"; exit 1; }
95+echo " release id ${release_id}${html_url:+ — ${html_url}}"
96+
97+for f in "${files[@]}"; do
98+ asset="$(basename "${f}")"
99+ echo "==> uploading ${asset} ($(wc -c < "${f}" | tr -d ' ') bytes)"
100+ encoded="$(jq -rn --arg n "${asset}" '$n|@uri')"
101+ status="$(request POST "${upload_url}?name=${encoded}" \
102+ -H 'Content-Type: application/octet-stream' --data-binary "@${f}")"
103+ case "${status}" in
104+ 200|201) ;;
105+ 422) echo "::error::asset ${asset} was refused (HTTP 422): $(message) — a partial earlier run? delete the release and retry"; exit 1 ;;
106+ *) echo "::error::uploading ${asset} failed (HTTP ${status}): $(message)"; exit 1 ;;
107+ esac
108+done
109+
110+echo "==> published ${tag} with ${#files[@]} asset(s)"