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_01FgkcT3UPxwxukBKojB5eg425cc220 parent: 30b5b18 modified
.github/workflows/ci.yml +2 -2 | @@ -23,10 +23,10 @@ jobs: | ||
| 23 | 23 | runs-on: ubuntu-latest |
| 24 | 24 | steps: |
| 25 | 25 | - name: Checkout |
| 26 | - uses: actions/checkout@v4 | |
| 26 | + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| 27 | 27 | |
| 28 | 28 | - name: Set up Go |
| 29 | - uses: actions/setup-go@v5 | |
| 29 | + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 | |
| 30 | 30 | with: |
| 31 | 31 | go-version-file: go.mod |
| 32 | 32 | cache: true |
| @@ -23,10 +23,10 @@ jobs: | |||
| 23 | runs-on: ubuntu-latest | 23 | runs-on: ubuntu-latest |
| 24 | steps: | 24 | steps: |
| 25 | - name: Checkout | 25 | - name: Checkout |
| 26 | - uses: actions/checkout@v4 | 26 | + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 |
| 27 | 27 | ||
| 28 | - name: Set up Go | 28 | - name: Set up Go |
| 29 | - uses: actions/setup-go@v5 | 29 | + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 |
| 30 | with: | 30 | with: |
| 31 | go-version-file: go.mod | 31 | go-version-file: go.mod |
| 32 | cache: true | 32 | cache: true |
modified
.github/workflows/rc.yml +12 -17 | @@ -26,10 +26,10 @@ jobs: | ||
| 26 | 26 | runs-on: ubuntu-latest |
| 27 | 27 | steps: |
| 28 | 28 | - name: Checkout |
| 29 | - uses: actions/checkout@v4 | |
| 29 | + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| 30 | 30 | |
| 31 | 31 | - name: Set up Go |
| 32 | - uses: actions/setup-go@v5 | |
| 32 | + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 | |
| 33 | 33 | with: |
| 34 | 34 | go-version-file: go.mod |
| 35 | 35 | cache: true |
| @@ -87,7 +87,7 @@ jobs: | ||
| 87 | 87 | echo "path=${RUNNER_TEMP}/rc-notes.md" >> "$GITHUB_OUTPUT" |
| 88 | 88 | |
| 89 | 89 | - name: Upload build artifacts |
| 90 | - uses: actions/upload-artifact@v4 | |
| 90 | + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| 91 | 91 | with: |
| 92 | 92 | name: rickub-${{ steps.version.outputs.version }} |
| 93 | 93 | path: dist/ |
| @@ -95,17 +95,12 @@ jobs: | ||
| 95 | 95 | retention-days: 14 |
| 96 | 96 | |
| 97 | 97 | - 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-latest | 26 | runs-on: ubuntu-latest |
| 27 | steps: | 27 | steps: |
| 28 | - name: Checkout | 28 | - name: Checkout |
| 29 | - uses: actions/checkout@v4 | 29 | + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 |
| 30 | 30 | ||
| 31 | - name: Set up Go | 31 | - name: Set up Go |
| 32 | - uses: actions/setup-go@v5 | 32 | + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 |
| 33 | with: | 33 | with: |
| 34 | go-version-file: go.mod | 34 | go-version-file: go.mod |
| 35 | cache: true | 35 | 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 artifacts | 89 | - name: Upload build artifacts |
| 90 | - uses: actions/upload-artifact@v4 | 90 | + 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: 14 | 95 | retention-days: 14 |
| 96 | 96 | ||
| 97 | - name: Publish prerelease | 97 | - name: Publish prerelease |
| 98 | - uses: softprops/action-gh-release@v2 | 98 | + 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 at | 103 | + 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: false | 106 | + 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: | ||
| 36 | 36 | runs-on: ubuntu-latest |
| 37 | 37 | steps: |
| 38 | 38 | - name: Checkout |
| 39 | - uses: actions/checkout@v4 | |
| 39 | + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| 40 | 40 | with: |
| 41 | 41 | # Full history + tags so the "tag already exists" check is meaningful. |
| 42 | 42 | fetch-depth: 0 |
| @@ -133,7 +133,7 @@ jobs: | ||
| 133 | 133 | esac |
| 134 | 134 | |
| 135 | 135 | - name: Set up Go |
| 136 | - uses: actions/setup-go@v5 | |
| 136 | + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 | |
| 137 | 137 | with: |
| 138 | 138 | go-version-file: go.mod |
| 139 | 139 | cache: true |
| @@ -178,7 +178,7 @@ jobs: | ||
| 178 | 178 | echo "path=${RUNNER_TEMP}/release-notes.md" >> "$GITHUB_OUTPUT" |
| 179 | 179 | |
| 180 | 180 | - name: Upload build artifacts |
| 181 | - uses: actions/upload-artifact@v4 | |
| 181 | + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| 182 | 182 | with: |
| 183 | 183 | name: rickub-${{ steps.check.outputs.version }} |
| 184 | 184 | path: dist/ |
| @@ -187,20 +187,15 @@ jobs: | ||
| 187 | 187 | |
| 188 | 188 | - name: Publish release |
| 189 | 189 | 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 | |
| 204 | 199 | |
| 205 | 200 | - name: Dry run summary |
| 206 | 201 | if: steps.check.outputs.dry_run == 'true' |
| @@ -36,7 +36,7 @@ jobs: | |||
| 36 | runs-on: ubuntu-latest | 36 | runs-on: ubuntu-latest |
| 37 | steps: | 37 | steps: |
| 38 | - name: Checkout | 38 | - name: Checkout |
| 39 | - uses: actions/checkout@v4 | 39 | + 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: 0 | 42 | fetch-depth: 0 |
| @@ -133,7 +133,7 @@ jobs: | |||
| 133 | esac | 133 | esac |
| 134 | 134 | ||
| 135 | - name: Set up Go | 135 | - name: Set up Go |
| 136 | - uses: actions/setup-go@v5 | 136 | + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 |
| 137 | with: | 137 | with: |
| 138 | go-version-file: go.mod | 138 | go-version-file: go.mod |
| 139 | cache: true | 139 | 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 artifacts | 180 | - name: Upload build artifacts |
| 181 | - uses: actions/upload-artifact@v4 | 181 | + 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 release | 188 | - 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@v2 | 190 | + 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 at | 195 | + 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: false | 198 | + 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 summary | 200 | - 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: | ||
| 9 | 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 | 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, | |
| 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. | |
| 15 | 17 | |
| 16 | 18 | --- |
| 17 | 19 | |
| 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 | |
| 57 | 58 | `release.yml` with **`dry_run: true`** — it builds, tests, verifies the tag is |
| 58 | 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 | 88 | 1. `go test ./...` |
| 88 | 89 | 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. | |
| 91 | 93 | |
| 92 | 94 | 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. | |
| 95 | 97 | |
| 96 | 98 | `concurrency: { group: rc, cancel-in-progress: true }` means only the newest |
| 97 | 99 | push to `main` is building at any moment; superseded RC runs are cancelled, so |
| @@ -122,9 +124,9 @@ The workflow then: | ||
| 122 | 124 | non-404 answer is only a warning — the git tag check is the real gate); |
| 123 | 125 | * runs `go vet` and `go test`; |
| 124 | 126 | * 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. | |
| 128 | 130 | |
| 129 | 131 | There is no approval gate: rickub CI silently drops `environment:`, so the |
| 130 | 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 | 210 | ## Troubleshooting |
| 209 | 211 | |
| 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. | |
| 212 | 217 | |
| 213 | 218 | **`Resource not accessible by integration` / `404` on release creation.** The |
| 214 | 219 | `GITHUB_TOKEN` is read-only by default; the workflow must declare |
| 215 | 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 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. | |
| 220 | 226 | |
| 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. | |
| 224 | 231 | |
| 225 | 232 | **Nothing to release / `make_latest` ignored.** The shim does not implement |
| 226 | 233 | `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 release | 13 | +[`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 allowlist | 24 | + |
| 23 | -today only covers the artifact/cache results service. The release REST shim | 25 | +**`uses:` actions are pinned to commit SHAs, not tags.** The runner fetches |
| 24 | -lives under the `/gh` prefix (`$GITHUB_API_URL`), and the proxy currently | 26 | +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` cannot | 27 | +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 | - ```go | 34 | +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 upload | 38 | +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 currently | 42 | +cuts the tag, `422` on a duplicate release or asset name, `upload_url` |
| 41 | - `GET / HEAD / POST / PUT` only. Release *creation* and asset upload are | 43 | +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 as | 48 | +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-checks | 50 | +[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 their | 53 | +`actions/upload-artifact` bundle, then fail on the "Publish …" step with |
| 52 | -`actions/upload-artifact` bundle successfully and then fail on the | 54 | +`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 is | 58 | `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 four | 90 | +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 at | 94 | 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 | 95 | +`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 newest | 98 | `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, so | 99 | 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>`, targeting | 127 | +* 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 tag | 128 | + `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 the | 131 | 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 | ## Troubleshooting | 210 | ## Troubleshooting |
| 209 | 211 | ||
| 210 | -**`403` publishing the release.** The `/gh` ciproxy allowlist prerequisite at | 212 | +**`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.** The | 218 | **`Resource not accessible by integration` / `404` on release creation.** The |
| 214 | `GITHUB_TOKEN` is read-only by default; the workflow must declare | 219 | `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 the | 222 | +**`422` creating the release or uploading an asset.** A release for that tag, |
| 218 | -release — the shim rejects duplicates. This normally means a partially | 223 | +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 will | 228 | +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 implement | 232 | **Nothing to release / `make_latest` ignored.** The shim does not implement |
| 226 | `make_latest` or `generate_release_notes`, and there is no | 233 | `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)" | ||