diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 1072c02..e589ed2 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -152,7 +152,96 @@ jobs: # One push for both refs: a commit that lands without its tag builds # nothing and looks like a successful release. - git push origin "HEAD:master" "refs/tags/v${VERSION}" + # + # The output is captured because a failing *post-receive* hook does not + # fail the push: git prints `remote: error: ...`, updates both refs and + # exits 0. That is how v1.0.3 was cut — the hook 500'd, so Gitea never + # created the build run, and this step went green anyway. + if ! git push origin "HEAD:master" "refs/tags/v${VERSION}" 2>push.log; then + cat push.log + echo "::error::Push failed. Nothing was released." + exit 1 + fi + cat push.log + + if grep -q '^remote: error' push.log; then + echo "::warning::The remote's post-receive hook errored. Both refs landed," + echo "::warning::but Gitea most likely created no workflow run for them." + echo "::warning::The next step checks and dispatches build.yml if needed." + fi + + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + id: push + + # Gitea creates workflow runs from the post-receive hook, so a hook error + # silently costs you the build: the tag exists, no image is ever published, + # and the failure only surfaces later as a 404 when deploy pulls the image. + # Confirm the run exists; dispatch it if it does not; fail loudly if that + # does not work either. + - name: Verify build.yml started + env: + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + VERSION: ${{ steps.ver.outputs.version }} + SHA: ${{ steps.push.outputs.sha }} + run: | + node -e ' + const base = `${process.env.GITHUB_SERVER_URL}/api/v1/repos/${process.env.GITHUB_REPOSITORY}`; + const headers = { Authorization: `token ${process.env.RELEASE_TOKEN}` }; + const sha = process.env.SHA; + const tag = `v${process.env.VERSION}`; + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + + const started = async () => { + const res = await fetch(`${base}/actions/runs?limit=30`, { headers }); + if (!res.ok) throw new Error(`runs query failed: HTTP ${res.status}`); + const body = await res.json(); + return (body.workflow_runs || []).some( + (r) => r.head_sha === sha && String(r.path || "").includes("build.yml"), + ); + }; + + // The hook fires synchronously with the push, so a run that is coming + // is usually already there; the retries cover a busy instance. + const poll = async (attempts) => { + for (let i = 0; i < attempts; i++) { + if (await started()) return true; + await sleep(10_000); + } + return started(); + }; + + (async () => { + if (await poll(3)) { + console.log(`build.yml is running for ${sha}.`); + return; + } + + console.log(`No build.yml run for ${sha}. Dispatching against ${tag}.`); + const res = await fetch( + `${base}/actions/workflows/build.yml/dispatches`, + { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + // Must be the tag, not master: metadata-action only emits the + // X.Y.Z and X.Y image tags when the ref is a semver tag. + body: JSON.stringify({ ref: tag }), + }, + ); + if (!res.ok) console.log(`Dispatch returned HTTP ${res.status}.`); + + if (await poll(3)) { + console.log(`build.yml is running for ${sha}.`); + return; + } + + console.log(`::error::${tag} is pushed but nothing is building it, and`); + console.log(`::error::the dispatch did not take. Run "Build and Push Images"`); + console.log(`::error::by hand with ref=${tag} (the tag, not master), then`); + console.log(`::error::deploy. Check the Gitea server log for the`); + console.log(`::error::post-receive error while you are at it.`); + process.exit(1); + })(); + ' - name: Summary env: