diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 358f3ab..a74ebec 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,6 +1,14 @@ # CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off # GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once -# this directory exists; .github/workflows stays as it was for GitHub. +# this directory exists. +# +# BUILD_ON: an org variable, set on both forges. Gitea push-mirrors this +# repository to GitHub, and with BUILD_ON=github the shellcheck job here skips +# and GitHub Actions runs the same lint on the mirrored copy +# (.github/workflows/ci.yml). The `github` job at the end waits for the result +# that workflow reports back, so this run still passes or fails with the lint. +# Unset, everything runs here as before -- which is also the fallback if +# GitHub is ever unavailable. # # Every job runs in an image pinned by digest (tag in the trailing comment), # and the only action used is coffey-labs/actions/checkout pinned by SHA. The @@ -19,6 +27,7 @@ concurrency: jobs: shellcheck: + if: ${{ vars.BUILD_ON != 'github' }} runs-on: light # The -alpine variant, not koalaman/shellcheck:stable. That one is built # FROM scratch with shellcheck as the entrypoint and no shell at all, so a @@ -34,3 +43,37 @@ jobs: [ -n "$files" ] || { echo "no shell scripts found"; exit 1; } echo "$files" | tr '\n' ' ' shellcheck --severity=warning --format=gcc $files + + # ------------------------------------------------------------ github ------ + # With BUILD_ON=github the jobs above skip and GitHub Actions builds this + # commit on the push-mirrored copy (.github/workflows/ci.yml). This job waits + # for the commit status that workflow reports back, and passes or fails with + # it, so this run -- what pull requests and merges look at -- still carries + # the real result. If GitHub never reports, it times out and fails: the cue + # to unset BUILD_ON and build here again. + github: + if: ${{ vars.BUILD_ON == 'github' }} + runs-on: light + timeout-minutes: 150 + container: + image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim + env: + READ_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SHA: ${{ github.event.pull_request.head.sha || github.sha }} + CONTEXT: ${{ github.ref_type == 'tag' && 'github/ci (tag)' || 'github/ci (branch)' }} + steps: + - run: apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl jq >/dev/null + - shell: bash + run: | + set -uo pipefail + url="${CI_SERVER_INTERNAL}/api/v1/repos/${GITHUB_REPOSITORY}/commits/${SHA}/statuses?limit=50" + echo "Waiting for '${CONTEXT}' on ${SHA}" + while :; do + # The newest status for the context is the one that counts. + s="$(curl -fsS -H "Authorization: token ${READ_TOKEN}" "$url" | jq -r --arg c "$CONTEXT" '[.[] | select(.context == $c)] | sort_by(.id) | last | "\(.status) \(.target_url)"' 2>/dev/null)" + case "$s" in + success\ *) echo "GitHub: ${s}"; exit 0 ;; + failure\ *|error\ *) echo "GitHub: ${s}" >&2; exit 1 ;; + esac + sleep 20 + done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..272411b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,88 @@ +# ShellCheck on GitHub Actions, for the GitHub replica of this repository. +# Gitea (git.coffeylabs.org) is the source of truth: it pushes every commit +# here through a push mirror, and this workflow lints what arrives. Pull +# requests live on Gitea, so there is no pull_request trigger -- a PR's branch +# reaches this copy as an ordinary push. +# +# Every file in this repository is a shell script that people are invited to +# run as root, so a lint gate is the cheapest guard against the class of bug +# that keeps showing up here: unquoted expansions, values interpolated into +# commands, and exit statuses that go unchecked. +# +# The switch is the org variable BUILD_ON, set on both forges. With +# BUILD_ON=github every job here runs, the job in .gitea/workflows/ci.yml +# skips, and Gitea's own run waits for the result this workflow reports back. +# With it unset, every job here skips and Gitea lints as before -- which is +# also the way back if GitHub is ever unavailable: unset it on Gitea. +# +# The job is .gitea/workflows/ci.yml's, in the same image pinned by digest, +# with the same settings (severity=warning, gcc format, every script in one +# run). The only action used is actions/checkout, pinned to a full commit SHA +# with the release in a trailing comment. +name: ci + +on: + push: + branches: ['**'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + GITEA_URL: ${{ vars.GITEA_URL }} + +jobs: + # Tells Gitea a result is on its way, so the commit does not look unchecked + # while the checks run. + pending: + if: ${{ vars.BUILD_ON == 'github' }} + runs-on: ubuntu-latest + steps: + - env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + CONTEXT: github/ci (${{ github.ref_type }}) + run: | + jq -n --arg c "$CONTEXT" --arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + '{state:"pending", context:$c, target_url:$u, description:"GitHub Actions"}' | + curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \ + --data @- "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" + + shellcheck: + if: ${{ vars.BUILD_ON == 'github' }} + runs-on: ubuntu-latest + # The -alpine variant, not koalaman/shellcheck:stable. That one is built + # FROM scratch with shellcheck as the entrypoint and no shell at all, so a + # job cannot run a script in it. + container: + image: koalaman/shellcheck-alpine:stable@sha256:c82fe42504fbc9fc68f15d36638e5ee2324ebb8b94e96a3c4e395bf361c49183 # stable + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Reproduces the settings ludeeus/action-shellcheck was given on GitHub: + # severity=warning, gcc format, every script in one run. + - run: | + files=$(find . -name '*.sh' -not -path './.git/*' | sort) + [ -n "$files" ] || { echo "no shell scripts found"; exit 1; } + echo "$files" | tr '\n' ' ' + shellcheck --severity=warning --format=gcc $files + + # One commit status on Gitea for the whole run. Gitea's ci.yml `github` job + # waits on exactly this context. + report: + if: ${{ always() && vars.BUILD_ON == 'github' }} + needs: [pending, shellcheck] + runs-on: ubuntu-latest + steps: + - env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + CONTEXT: github/ci (${{ github.ref_type }}) + STATE: ${{ (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) && 'failure' || 'success' }} + run: | + jq -n --arg s "$STATE" --arg c "$CONTEXT" --arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + '{state:$s, context:$c, target_url:$u, description:"GitHub Actions"}' | + curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \ + --data @- "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml deleted file mode 100644 index 855c32b..0000000 --- a/.github/workflows/shellcheck.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: ShellCheck - -# Every file in this repository is a shell script that people are -# invited to run as root, so a lint gate is the cheapest guard against -# the class of bug that keeps showing up here: unquoted expansions, -# values interpolated into commands, and exit statuses that go unchecked. -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - -permissions: - contents: read - -jobs: - shellcheck: - name: ShellCheck - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Run ShellCheck - uses: ludeeus/action-shellcheck@master - with: - # warning, not error: the first run at this level surfaced - # exactly two findings and both were fixed, so there is no - # pre-existing backlog to grandfather in. Anything new that - # trips it is genuinely new. Dropping to the default (info) - # would also pull in style suggestions across every script -- - # worth doing, but as its own pass. - severity: warning - check_together: 'yes' - format: gcc