Publish from CI without a secret
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline failed

Woodpecker hands every step a forge credential for cloning — an access token of the
repository's owner — and a one-off diagnostic in the check step confirmed it is there.
scripts/ci-upload.sh now uses it when no `gitea_token` secret is set, so publishing a
release needs nothing configured. Gitea takes such a credential as `token …` or
`Bearer …` depending on how Woodpecker was set up, so the script probes which of the two
`/user` accepts rather than assuming, and says which one it used.

The secret mapping is gone from the step as well: referencing a secret that does not
exist is a failure mode of its own, and the fallback is the normal path now. Adding a
`gitea_token` secret and mapping it back in is how you publish as somebody else.

Documents the release flow the pipeline now implements: push a vX.Y.Z tag, the pipeline
builds Linux and Windows and creates the release with them in it, and `make release` from
a Mac pushes the macOS package onto the same release. Either half can go first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 17:15:58 +02:00
co-authored by Claude Opus 5
parent 2f725fdd14
commit 7026e0cc6a
4 changed files with 63 additions and 30 deletions
+3 -6
View File
@@ -23,9 +23,6 @@ steps:
image: *node_image image: *node_image
commands: commands:
- node --version - node --version
# One-off diagnostic: does Woodpecker hand steps a forge credential of their own?
# If it does, the release step needs no secret at all.
- 'echo "netrc user: ${CI_NETRC_USERNAME:+present}, password: ${CI_NETRC_PASSWORD:+present}"'
- npm ci - npm ci
- npm run typecheck - npm run typecheck
- npm run lint - npm run lint
@@ -61,9 +58,9 @@ steps:
# from the Mac that can sign them. # from the Mac that can sign them.
- name: release - name: release
image: alpine image: alpine
environment: # No secret: the step authenticates with the forge credential Woodpecker already
GITEA_TOKEN: # hands every step, which belongs to the repository's owner. To publish as someone
from_secret: gitea_token # else instead, add a `gitea_token` repository secret and map it here as GITEA_TOKEN.
commands: commands:
- apk add --no-cache curl jq - apk add --no-cache curl jq
# No globs on the command line: the package names have spaces in them. # No globs on the command line: the package names have spaces in them.
+26 -13
View File
@@ -192,17 +192,28 @@ builds an application and publishes to a release.
| `check` | `electronuserland/builder:22` | `npm ci`, type-check, lint, and the smoke test | | `check` | `electronuserland/builder:22` | `npm ci`, type-check, lint, and the smoke test |
| `linux` | `electronuserland/builder:22` | AppImage and deb | | `linux` | `electronuserland/builder:22` | AppImage and deb |
| `windows` | `electronuserland/builder:22-wine` | the NSIS installer and the portable exe, built through Wine | | `windows` | `electronuserland/builder:22-wine` | the NSIS installer and the portable exe, built through Wine |
| `release` | `alpine` | on a tag only: attaches what this pipeline built | | `release` | `alpine` | on a tag only: **creates the release** and attaches what this pipeline built |
**macOS stays a local build.** Apple's toolchain and its signing only exist on a Mac, **macOS stays a local build.** Apple's toolchain and its signing only exist on a Mac.
so a full release is `make release` here for the macOS package plus this pipeline for So the whole of a release is:
the other two. The window test is local too: it needs a display and a store on the
machine.
The `release` step needs a **`gitea_token`** repository secret in Woodpecker, with 1. bump the version, commit, and push the tag: `git tag v1.4.0 && git push origin v1.4.0`;
write access to this repository — CI has a token where a workstation has a `tea` 2. the pipeline builds Linux and Windows, **creates the release** with `RELEASE_NOTES.md`
login, which is why `scripts/ci-upload.sh` exists alongside `scripts/release.sh` as its body, and attaches those four packages;
instead of one script with two ways to authenticate. 3. on a Mac, `make release` builds the macOS package and pushes it onto the same release.
The window test is local as well: it needs a display and a store on the machine.
The `release` step needs **no secret**. It authenticates with the forge credential
Woodpecker hands every step for cloning, which is an access token of the repository's
owner; Gitea takes it as `token …` or `Bearer …` depending on how Woodpecker was set up,
so `scripts/ci-upload.sh` probes which of the two works instead of assuming. To publish
as somebody else, add a `gitea_token` repository secret and map it into the step.
There are two publishers on purpose: `scripts/release.sh` drives `tea`, which is logged
in on a workstation, and `scripts/ci-upload.sh` speaks the API with whatever credential
CI has. Each is short enough to read in full; one script with two ways to authenticate
would not be.
Both build steps end by checking what they produced: a package under 10 MB did not Both build steps end by checking what they produced: a package under 10 MB did not
finish. That check exists because a half-finished Wine build leaves a stub *named* like finish. That check exists because a half-finished Wine build leaves a stub *named* like
@@ -224,10 +235,12 @@ convention.
make release make release
``` ```
The tag comes from `package.json`, so `npm version patch` is the only place a This is the **macOS half** of a release; the Linux and Windows packages come from the
version is set. The release is created if it is not there yet, and an attachment pipeline when the tag is pushed (see above). The tag comes from `package.json`, so
whose name is already on it is **replaced** rather than refused — so a rebuild and `npm version patch` is the only place a version is set. The release is created if it is
a second `make publish` lands rather than erroring. not there yet — either half can go first — and an attachment whose name is already on it
is **replaced** rather than refused, so a rebuild and a second `make publish` lands
rather than erroring.
Release notes come from `RELEASE_NOTES.md` when the file is present, otherwise the Release notes come from `RELEASE_NOTES.md` when the file is present, otherwise the
release gets a one-line note. The repository is read from `origin`, so a fork release gets a one-line note. The repository is read from `origin`, so a fork
+9 -5
View File
@@ -58,11 +58,15 @@ window failed it.
### Linux and Windows packages now come from CI ### Linux and Windows packages now come from CI
`.woodpecker.yaml` builds the AppImage, the deb, the NSIS installer and the portable A `vX.Y.Z` tag now starts the pipeline, which builds the AppImage, the deb, the NSIS
exe — Windows through Wine — and on a tag attaches them to this release. macOS stays a installer and the portable exe — Windows through Wine — **creates this release** and
local build, because Apple's toolchain and its signing exist only on a Mac, so a full attaches all four. macOS stays a local build, because Apple's toolchain and its signing
release is one local `make release` plus the pipeline. The Windows installer is not exist only on a Mac, so `make release` from a Mac pushes that package onto the same
signed: Windows will warn about an unknown publisher until there is a certificate. release afterwards. No secret is involved: the pipeline publishes with the forge
credential Woodpecker already gives every step.
The Windows installer is not signed: Windows will warn about an unknown publisher until
there is a certificate.
### Opening it on macOS ### Opening it on macOS
+25 -6
View File
@@ -6,8 +6,11 @@
# two are deliberately separate scripts rather than one with two ways to authenticate — # two are deliberately separate scripts rather than one with two ways to authenticate —
# each is short enough to read in full. # each is short enough to read in full.
# #
# GITEA_TOKEN=… scripts/ci-upload.sh every package in dist/ # scripts/ci-upload.sh every package in dist/
# GITEA_TOKEN=… scripts/ci-upload.sh dist/one.deb just these # scripts/ci-upload.sh dist/one.deb just these
#
# Authenticates with the `gitea_token` secret when there is one, and otherwise with the
# credential Woodpecker gives every step for cloning — so a release needs no secret.
# #
# Creates the release when the tag has none, with RELEASE_NOTES.md as its body. That is # Creates the release when the tag has none, with RELEASE_NOTES.md as its body. That is
# the flow: a `vX.Y.Z` tag starts this pipeline, which publishes the release with the # the flow: a `vX.Y.Z` tag starts this pipeline, which publishes the release with the
@@ -24,13 +27,30 @@ NOTES="${NOTES:-RELEASE_NOTES.md}"
say() { echo "[ci-upload] $*"; } say() { echo "[ci-upload] $*"; }
die() { echo "[ci-upload] error: $*" >&2; exit 1; } die() { echo "[ci-upload] error: $*" >&2; exit 1; }
[ -n "${GITEA_TOKEN:-}" ] || die "GITEA_TOKEN is not set — add the gitea_token secret to the repository" # Who to be. A `gitea_token` secret wins when there is one; otherwise the credential
# Woodpecker already hands every step for cloning is used, which is an access token of
# the repository's owner — so publishing needs no secret of its own. Gitea accepts a
# personal access token as `token …` and an OAuth one as `Bearer …`, and which of the two
# this is depends on how Woodpecker was set up, so the scheme is probed once rather than
# assumed.
TOKEN="${GITEA_TOKEN:-${CI_NETRC_PASSWORD:-}}"
[ -n "$TOKEN" ] || die "no credential: set GITEA_TOKEN, or run this where Woodpecker provides CI_NETRC_PASSWORD"
[ -n "$REPO" ] || die "cannot work out the repository — set REPO=owner/name" [ -n "$REPO" ] || die "cannot work out the repository — set REPO=owner/name"
[ -n "$TAG" ] || die "cannot work out the tag — set TAG=v1.2.3" [ -n "$TAG" ] || die "cannot work out the tag — set TAG=v1.2.3"
AUTH=""
for scheme in token Bearer; do
if curl -fsS -H "Authorization: $scheme $TOKEN" "$FORGE/user" >/dev/null 2>&1; then
AUTH="Authorization: $scheme $TOKEN"
say "authenticated with the $scheme scheme"
break
fi
done
[ -n "$AUTH" ] || die "the credential was refused by $FORGE — it cannot read /user"
api() { api() {
method="$1"; path="$2"; shift 2 method="$1"; path="$2"; shift 2
curl -fsS -X "$method" -H "Authorization: token $GITEA_TOKEN" "$FORGE$path" "$@" curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@"
} }
# Package names contain spaces — "WarpEngine Store Setup 1.4.0.exe" does — so the list # Package names contain spaces — "WarpEngine Store Setup 1.4.0.exe" does — so the list
@@ -52,8 +72,7 @@ say "$REPO $TAG"
# `curl -f` fails on the 404 a missing release answers, so the lookup is allowed to # `curl -f` fails on the 404 a missing release answers, so the lookup is allowed to
# fail and judged by what came back rather than by its exit status. # fail and judged by what came back rather than by its exit status.
release_id="$(curl -sS -H "Authorization: token $GITEA_TOKEN" \ release_id="$(curl -sS -H "$AUTH" "$FORGE/repos/$REPO/releases/tags/$TAG" | jq -r '.id // empty')"
"$FORGE/repos/$REPO/releases/tags/$TAG" | jq -r '.id // empty')"
if [ -z "$release_id" ]; then if [ -z "$release_id" ]; then
say "no release for $TAG yet — creating it" say "no release for $TAG yet — creating it"