From 21daea617bf9d97428c5d553e2228368584139ed Mon Sep 17 00:00:00 2001 From: darksworm <9987548+darksworm@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:17:19 +0200 Subject: [PATCH] ci: publish tagged releases with release-please and GoReleaser Ported from argonaut, trimmed to what a self-hosted daemon needs: the container image is the only distribution channel, so the AUR, Nix, Homebrew, npm and native-package parts of that pipeline are left out. release-please keeps a release PR open from the conventional-commit history. Merging it tags the commit and drafts release notes; GoReleaser then cross-compiles for amd64 and arm64, pushes the image to ghcr.io under version, minor, major and latest tags, and the release is undrafted once the image is pullable. Every published image is gated on the test suite. docker-publish.yml is replaced. It pushed :latest from every commit on main, so :latest now means the newest release instead of the newest commit. Its test gate moved into the release pipeline. Dockerfile.release packages the binary GoReleaser has already cross-compiled, so it has no RUN step and needs no emulation to build the arm64 image. The existing Dockerfile stays for local builds. test.yml additionally checks gofmt and runs staticcheck. Two deprecations staticcheck flags are fixed to keep it green: io/ioutil, deprecated since Go 1.19, and net.Dialer.DualStack, a no-op since Go 1.12. Neither changes behaviour. scripts/rotate-release-token.sh sets up and rotates the one secret the pipeline needs. GitHub has no API for creating a personal access token, so the script opens the form, says what to tick, then authenticates as the pasted token, refuses it unless it can write to the repository, warns when it expires sooner than 180 days, and reads the stored secret back. The token is never echoed and never passed as an argument. --- .github/workflows/docker-publish.yml | 68 ------- .github/workflows/goreleaser.yml | 88 +++++++++ .github/workflows/release-pipeline.yml | 55 ++++++ .github/workflows/test.yml | 24 ++- .gitignore | 3 + .goreleaser.release.yml | 60 +++++++ .release-please-manifest.json | 3 + CONTRIBUTING.md | 18 ++ Dockerfile.release | 18 ++ README.md | 16 ++ main.go | 4 +- release-please-config.json | 9 + scripts/rotate-release-token.sh | 239 +++++++++++++++++++++++++ 13 files changed, 531 insertions(+), 74 deletions(-) delete mode 100644 .github/workflows/docker-publish.yml create mode 100644 .github/workflows/goreleaser.yml create mode 100644 .github/workflows/release-pipeline.yml create mode 100644 .goreleaser.release.yml create mode 100644 .release-please-manifest.json create mode 100644 Dockerfile.release create mode 100644 release-please-config.json create mode 100755 scripts/rotate-release-token.sh diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index 7e0e591..0000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Docker Build and Publish - -on: - push: - branches: [ main ] - paths: - - '**/*.go' - - 'go.mod' - - 'go.sum' - - 'Dockerfile' - # IMAGE_NAME follows the repository name, so renaming the repo changes where - # images are published. Nothing pushes on a rename, though, and the paths filter - # above means an unrelated commit will not do it either. This lets you publish - # the first image under a new name by hand. - workflow_dispatch: - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - test: - uses: ./.github/workflows/test.yml - - build-and-push: - needs: test - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Log in to the Container registry - uses: docker/login-action@v2 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v4 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=sha,format=long - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - latest - - - name: Build and push Docker image - uses: docker/build-push-action@v4 - with: - context: . - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - platforms: linux/amd64,linux/arm64,linux/arm64/v8 - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml new file mode 100644 index 0000000..e9432a7 --- /dev/null +++ b/.github/workflows/goreleaser.yml @@ -0,0 +1,88 @@ +name: goreleaser + +# Builds the release artifacts for an existing tag and pushes the container +# image. Called by release-pipeline.yml right after release-please cuts a tag, +# and available on its own to rebuild a tag whose image push failed. +on: + workflow_call: + inputs: + tag: + description: "Tag to build and publish the image for" + required: true + type: string + workflow_dispatch: + inputs: + tag: + description: "Existing tag to (re)build and publish the image for, e.g. v0.1.0" + required: true + type: string + +permissions: + contents: write + packages: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout tag + uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag }} + # GoReleaser derives the version from the tag, so a shallow checkout + # without tags would make it fall back to a snapshot version. + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to ghcr.io + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: "~> v2.18" + args: release --config .goreleaser.release.yml --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # release-please creates the release as a draft so it only becomes visible + # once the image it describes is actually pullable. + - name: Publish release + uses: actions/github-script@v9 + env: + TAG: ${{ inputs.tag }} + with: + script: | + const { owner, repo } = context.repo; + const tag = process.env.TAG; + + // getReleaseByTag does not return drafts, so list and match. + const releases = await github.paginate( + github.rest.repos.listReleases, + { owner, repo, per_page: 100 } + ); + const release = releases.find(r => r.tag_name === tag); + if (!release) { + throw new Error(`No release found for tag ${tag}`); + } + + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: release.id, + draft: false, + }); + + console.log(`Published ${tag}: ${release.html_url}`); diff --git a/.github/workflows/release-pipeline.yml b/.github/workflows/release-pipeline.yml new file mode 100644 index 0000000..e170e77 --- /dev/null +++ b/.github/workflows/release-pipeline.yml @@ -0,0 +1,55 @@ +name: release-pipeline + +# On every push to main, release-please keeps a release PR up to date from the +# conventional-commit history. Merging that PR is what cuts a release: it tags +# the commit, drafts the release notes, and hands the tag to goreleaser, which +# builds and pushes the versioned image before the release goes public. +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + packages: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - name: release + id: release + uses: googleapis/release-please-action@v5 + with: + # A PAT rather than GITHUB_TOKEN: pushes and PRs made with + # GITHUB_TOKEN do not trigger workflows, so the release PR would + # never run the tests it is meant to gate on. + token: ${{ secrets.RELEASE_PLEASE_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + # Nothing gets published without the tests passing. docker-publish.yml used to + # provide this gate; the release pipeline took over its job. + test: + needs: release-please + if: ${{ needs.release-please.outputs.release_created == 'true' }} + uses: ./.github/workflows/test.yml + + # No `secrets: inherit`: the called workflow needs nothing beyond + # GITHUB_TOKEN, which reusable workflows always get, and inheriting would + # hand it RELEASE_PLEASE_TOKEN for no reason. + # + # If this job fails, the tag and the draft release already exist, so a later + # push to main will not retry it — release-please only reports + # release_created once. Re-run the goreleaser workflow directly instead; it + # takes the tag as a workflow_dispatch input for exactly this case. + goreleaser: + needs: [release-please, test] + if: ${{ needs.release-please.outputs.release_created == 'true' }} + uses: ./.github/workflows/goreleaser.yml + with: + tag: ${{ needs.release-please.outputs.tag_name }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5eca78b..97aee65 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,8 +1,8 @@ name: Test -# pull_request covers review; workflow_call lets docker-publish gate on this -# same job instead of duplicating it. Deliberately no push trigger: it would -# double-run on main, once here and once through the call. +# pull_request covers review; workflow_call lets the release pipeline gate on +# this same job instead of duplicating it. Deliberately no push trigger: it +# would double-run on main, once here and once through the call. on: pull_request: workflow_call: @@ -17,14 +17,32 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 + # setup-go caches the module and build caches by default, keyed on go.sum. - name: Set up Go uses: actions/setup-go@v7 with: go-version-file: go.mod + - name: Check formatting (gofmt) + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "Files not gofmt-formatted:" + echo "$unformatted" + exit 1 + fi + - name: Vet run: go vet ./... + # staticcheck itself needs a newer Go than the one go.mod pins for + # doormouse, so let the go command fetch that toolchain for this step. + # Tests still run on the version go.mod declares. + - name: Run staticcheck + run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./... + env: + GOTOOLCHAIN: auto + # -race catches the data races the ManualClock tests are built to expose; # -shuffle=on stops tests depending on declaration order. - name: Test diff --git a/.gitignore b/.gitignore index 042293f..751b0cd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ doormouse go-wol-proxy *.migrated.toml + +# goreleaser output +dist/ diff --git a/.goreleaser.release.yml b/.goreleaser.release.yml new file mode 100644 index 0000000..949245c --- /dev/null +++ b/.goreleaser.release.yml @@ -0,0 +1,60 @@ +version: 2 + +project_name: doormouse + +before: + hooks: + - go mod download + +builds: + - id: doormouse + main: . + binary: doormouse + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w + # doormouse is a Linux daemon that has to sit in the target's broadcast + # domain, and the container image is the only release channel, so there is + # nothing to gain from darwin/windows builds. + goos: [linux] + goarch: [amd64, arm64] + +# The container image is the only published artifact, so goreleaser neither +# builds archives nor touches the GitHub release. release-please owns the +# release and its notes; the workflow undrafts it once the image is pushed. +archives: + - formats: [binary] + +release: + disable: true + +changelog: + disable: true + +dockers_v2: + - id: image + dockerfile: Dockerfile.release + ids: [doormouse] + images: + - ghcr.io/darksworm/doormouse + # Rolling tags let a compose file track a major or minor line and still get + # patch updates. :latest stays for the quick start in the README. + tags: + - "{{ .Version }}" + - "{{ .Major }}.{{ .Minor }}" + - "{{ .Major }}" + - latest + platforms: + - linux/amd64 + - linux/arm64 + labels: + org.opencontainers.image.created: "{{ .Date }}" + org.opencontainers.image.title: "{{ .ProjectName }}" + org.opencontainers.image.description: "A reverse proxy that wakes your servers when someone knocks" + org.opencontainers.image.revision: "{{ .FullCommit }}" + org.opencontainers.image.version: "{{ .Version }}" + org.opencontainers.image.licenses: "GPL-3.0-or-later" + org.opencontainers.image.source: "https://github.com/darksworm/doormouse" diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec22215..cdcccd5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,24 @@ Common types are: - `docs` for documentation changes - `chore` for maintenance +Commit types decide the next version number, so pick them with care. +`feat` bumps the minor version and `fix` the patch version; `docs` and `chore` +do not trigger a release. Mark a breaking change with a `!` after the type, as +in `feat!: drop the old target syntax`. + +## Releases +Releases are automatic. [release-please](https://github.com/googleapis/release-please) +reads the commits landing on `main` and keeps a release pull request open with +the next version and a generated changelog. Merging that pull request tags the +release and publishes the container image; nothing else needs doing by hand. + +The pipeline needs one secret, `RELEASE_PLEASE_TOKEN`, a personal access token +with write access to contents, pull requests and issues. When it expires, +release pull requests simply stop appearing. Run +`scripts/rotate-release-token.sh` to set it up or replace it: it opens the form, +says what to tick, checks the token can actually write to the repository before +storing it, and prints the date it expires. + ## Pull requests - Rebase on the latest `main` branch before submitting. - Keep commits focused; separate unrelated changes. diff --git a/Dockerfile.release b/Dockerfile.release new file mode 100644 index 0000000..c731652 --- /dev/null +++ b/Dockerfile.release @@ -0,0 +1,18 @@ +# Runtime image for released versions. Unlike the top-level Dockerfile, which +# compiles from source for local builds, this one only packages the binary +# GoReleaser has already cross-compiled — so there is no RUN step and no +# emulation cost when building the arm64 image on an amd64 runner. +FROM alpine:3.22 + +WORKDIR /app + +# GoReleaser dockers_v2 places each platform's binary under $TARGETPLATFORM/ +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/doormouse /app/doormouse + +# Same contract as the source-built image: the default port, and a config +# mounted at /app/config.toml. TCP routes listen on their own ports; with +# network_mode: host they are reachable directly, otherwise publish each one. +EXPOSE 8080 + +ENTRYPOINT ["/app/doormouse", "/app/config.toml"] diff --git a/README.md b/README.md index cd8a6fe..6a9e436 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,22 @@ or checked into a config repo. A config may use one format or the other, never both. +## Container images + +Every release publishes an image to `ghcr.io/darksworm/doormouse`, built for +`linux/amd64` and `linux/arm64`. Four tags point at it: + +| Tag | Points at | +| --- | --- | +| `0.4.1` | that exact release, and never moves | +| `0.4` | the newest patch in the 0.4 line | +| `0` | the newest release in the 0.x line | +| `latest` | the newest release | + +`latest` is fine for trying doormouse out. Once it is proxying something you +care about, pin the exact version or the minor line, so an upgrade happens when +you choose it. + ## Building from source ```bash diff --git a/main.go b/main.go index 97ac649..a774c09 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" "log" "net" "net/http" @@ -487,7 +486,7 @@ func NewDefaultSSHExecutor(logger Logger) *DefaultSSHExecutor { func (s *DefaultSSHExecutor) ExecuteCommand(host, user, keyPath, command string) error { // Read private key - key, err := ioutil.ReadFile(keyPath) + key, err := os.ReadFile(keyPath) if err != nil { return fmt.Errorf("unable to read private key: %w", err) } @@ -1259,7 +1258,6 @@ func (p *ProxyService) proxyRequest(w http.ResponseWriter, r *http.Request, rout DialContext: (&net.Dialer{ Timeout: 60 * time.Second, // Increased timeout for slow connections KeepAlive: 60 * time.Second, // Increased keep-alive - DualStack: true, }).DialContext, ForceAttemptHTTP2: true, MaxIdleConns: 100, diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..64c118b --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "go", + "draft": true, + "force-tag-creation": true, + "packages": { + ".": {} + } +} diff --git a/scripts/rotate-release-token.sh b/scripts/rotate-release-token.sh new file mode 100755 index 0000000..2ab9ac6 --- /dev/null +++ b/scripts/rotate-release-token.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# +# Set up or rotate RELEASE_PLEASE_TOKEN, the personal access token the release +# pipeline uses to open release pull requests and cut tags. +# +# GitHub has no API for creating a personal access token, so one paste is +# unavoidable. This script does the rest: opens the form, tells you exactly what +# to tick, then checks the token really works before installing it, so a wrong +# permission fails here rather than quietly three weeks later when a release is +# due. +# +set -euo pipefail + +REPO="${REPO:-darksworm/doormouse}" +SECRET_NAME="RELEASE_PLEASE_TOKEN" +NEW_TOKEN_URL="https://github.com/settings/personal-access-tokens/new" + +usage() { + cat < $REPO + Contents ............. Read and write (commits, tags, releases) + Pull requests ........ Read and write (the release pull request) + Issues ............... Read and write (labels on the release pull request) + +Then: + scripts/rotate-release-token.sh open the form, then prompt + pass show gh/doormouse-release | scripts/rotate-release-token.sh + scripts/rotate-release-token.sh --dry-run validate it, install nothing + scripts/rotate-release-token.sh --no-browser prompt without opening a browser + +Set REPO in the environment to target a different repository. +EOF +} + +dry_run=false +open_browser=true +for arg in "$@"; do + case "$arg" in + --dry-run) dry_run=true ;; + --no-browser) open_browser=false ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "unknown argument: $arg" >&2 + usage >&2 + exit 2 + ;; + esac +done + +command -v gh >/dev/null || { + echo "error: the GitHub CLI (gh) is required" >&2 + exit 1 +} + +# Hand the URL to whatever opens links here. Backgrounded because some browsers +# hold the terminal, and best-effort because plenty of machines have no opener at +# all — over SSH, in a container. +open_url() { + if [ -n "${BROWSER:-}" ] && command -v "${BROWSER%% *}" >/dev/null 2>&1; then + "$BROWSER" "$1" >/dev/null 2>&1 & + return 0 + fi + for opener in xdg-open open wslview; do + if command -v "$opener" >/dev/null 2>&1; then + "$opener" "$1" >/dev/null 2>&1 & + return 0 + fi + done + return 1 +} + +# Read the token from a prompt when there is a terminal, otherwise from stdin so +# it can be piped out of a password manager. Never echoed, and never passed as an +# argument, so it stays out of the shell history and out of ps. +if [ -t 0 ]; then + cat >&2 < $REPO + + Repository permissions, all three set to Read and write: + Contents ........... commits the changelog, creates tags and releases + Pull requests ...... opens and updates the release pull request + Issues ............. the autorelease label on that pull request + + Metadata turns read-only by itself. Leave the rest alone; the summary + should say "3 permissions" before you generate. + +EOF + if [ "$open_browser" = true ] && open_url "$NEW_TOKEN_URL"; then + echo "Opening $NEW_TOKEN_URL" >&2 + else + echo "Open $NEW_TOKEN_URL" >&2 + fi + echo >&2 + printf 'Paste the token here (input hidden): ' >&2 + IFS= read -rs token + printf '\n' >&2 +else + # An empty stdin makes read fail, which would otherwise end the script here + # without explanation. Fall through to the "no token given" check instead. + IFS= read -r token || true +fi + +# Trim the whitespace a copy-paste tends to bring along. +token="${token#"${token%%[![:space:]]*}"}" +token="${token%"${token##*[![:space:]]}"}" + +[ -n "$token" ] || { + echo "error: no token given" >&2 + exit 1 +} + +# Authenticate as the new token rather than as whoever is running this. Both +# variable names are set because gh prefers GH_TOKEN but honours either. +as_new_token() { + GH_TOKEN="$token" GITHUB_TOKEN="$token" gh "$@" +} + +echo "==> Checking the token against $REPO" + +# .permissions.push is true only when the token holds Contents: Read and write. +# Without it release-please cannot commit the changelog or create the tag. +if ! repo_info=$(as_new_token api "repos/$REPO" -q '[.full_name, .permissions.push] | @tsv' 2>&1); then + # gh prints the API's JSON and then its own one-line summary, tacked onto the + # JSON's closing brace without a newline. The summary is the useful half. + reason=$(printf '%s\n' "$repo_info" | grep -o 'gh: .*' | head -1 || true) + [ -n "$reason" ] || reason=$(printf '%s\n' "$repo_info" | tail -1) + echo "error: the token cannot read $REPO" >&2 + echo " ${reason#gh: }" >&2 + echo " Check the resource owner, and that repository access covers $REPO." >&2 + exit 1 +fi + +full_name=${repo_info%%$'\t'*} +can_push=${repo_info##*$'\t'} + +if [ "$can_push" != "true" ]; then + echo "error: the token has read-only access to $full_name." >&2 + echo " Set Contents to 'Read and write' and try again." >&2 + exit 1 +fi +echo " can write to $full_name" + +# A token's expiry date comes back as a response header, so ask for the headers. +# A token with an expiry date carries one; one set never to expire does +# not, which is worth saying out loud rather than reporting as unknown. +# +# Captured into a variable and parsed in one command on purpose. Piping gh into +# awk and letting awk exit on the first match closes the pipe early, and under +# `set -eo pipefail` the resulting SIGPIPE takes the whole script down without a +# word — which it did, but only for tokens that actually carry the header. +headers=$(as_new_token api -i "repos/$REPO" 2>/dev/null || true) +expiry=$(awk -F': ' ' + tolower($1) == "github-authentication-token-expiration" { + sub(/\r$/, "", $2) + print $2 + }' <<<"$headers") + +if [ -n "$expiry" ]; then + echo " expires $expiry" +else + echo " no expiry reported (a token set never to expire)" +fi + +# GitHub's expiry dropdown defaults to 30 days, which is easy to accept by +# accident and means being back here in a month. Say so before storing, while +# going back to the form is still cheap. `|| true` because parsing the date +# needs GNU date, and a machine without it should not fail the rotation. +MIN_DAYS=${MIN_DAYS:-180} +if [ -n "$expiry" ]; then + expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null || true) + if [ -n "$expiry_epoch" ]; then + days_left=$(((expiry_epoch - $(date +%s)) / 86400)) + if [ "$days_left" -lt "$MIN_DAYS" ]; then + echo + echo "warning: this token lasts $days_left days, less than the $MIN_DAYS this script" + echo " expects. GitHub's Expiration dropdown defaults to 30 days; pick" + echo " Custom and set a date up to a year out instead." + echo + if [ "$dry_run" = false ] && [ -t 0 ]; then + printf 'Store it anyway? [y/N] ' >&2 + IFS= read -r reply || true + case "$reply" in + y | Y | yes | YES) ;; + *) + echo "Nothing stored. Mint a longer-lived token and run this again." >&2 + exit 1 + ;; + esac + fi + fi + fi +fi + +if [ "$dry_run" = true ]; then + echo "==> --dry-run: the secret was not changed" + exit 0 +fi + +echo "==> Storing $SECRET_NAME on $REPO" +printf '%s' "$token" | gh secret set "$SECRET_NAME" --repo "$REPO" + +# Read it back, so "stored" means the API agrees rather than that gh exited 0. +# `|| true` because a hiccup listing secrets must not report the store as failed. +stored=$(gh secret list --repo "$REPO" 2>/dev/null | + awk -v n="$SECRET_NAME" '$1 == n { print }' || true) +if [ -n "$stored" ]; then + echo " $stored" +else + echo "warning: the secret was set but does not show up in the list" >&2 +fi + +cat <